diff --git a/.github/RELEASE.md b/.github/RELEASE.md index e394c47f..9f3f6567 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -2,26 +2,72 @@ ## Overview -Releases are automated via GitHub Actions. When you push a tag starting with `v` (e.g., `v0.1.0`), the release workflow builds and publishes distributables for macOS, Windows, and Linux. +Releases follow Git Flow and are fully automated via GitHub Actions. + +## Flow + +``` +develop → release/X.Y.Z branch → PR to main → merge + ↓ auto-tag.yml triggers: + 1. Creates git tag vX.Y.Z from package.json version + 2. Merges main → develop (keeps branches aligned) + ↓ release.yml triggers (on tag push): + 3. Builds distributables (macOS, Windows, Linux) + 4. Creates draft GitHub Release with artifacts + 5. Posts tweet announcement +``` ## Creating a Release ```bash -# 1. Update version in apps/desktop/package.json -# 2. Commit the change -git add apps/desktop/package.json -git commit -m "chore: bump version to 0.1.0" - -# 3. Create and push the tag -git tag v0.1.0 -git push origin main --tags +# 1. Create release branch from develop +git checkout develop +git pull origin develop +git checkout -b release/0.9.0 + +# 2. Bump version in root package.json + apps/desktop/package.json +# 3. Update CHANGELOG.md +# 4. Commit and push +git add -A +git commit -m "chore(release): bump version to 0.9.0" +git push -u origin release/0.9.0 + +# 5. Create PR targeting main +gh pr create --base main --title "chore(release): v0.9.0" --body "Release 0.9.0" + +# 6. Once CI passes and PR merges → tag + release + sync happen automatically ``` -The workflow will: +The release workflow will: + +1. Validate tag matches `package.json` version +2. Build packages for all platforms (macOS, Windows, Linux) +3. Sign and notarize the macOS build (if secrets are configured) +4. Create a draft GitHub Release with all artifacts +5. Post tweet announcement + +## Versioning + +- **Format:** SemVer with `v` prefix — `v0.9.0`, `v0.9.1`, `v1.0.0` +- **Tags are created ONLY by GitHub Actions** (auto-tag.yml) +- **No manual tags** — the automation reads version from `package.json` + +## Tag Protection Rules (GitHub Settings) + +Configure in **Repository Settings > Rules > Tag protection rules**: + +| Setting | Value | +| ----------------- | --------------------------------- | +| Tag name pattern | `v*` | +| Restrict creation | Enabled | +| Allowed to create | GitHub Actions, Repository admins | +| Force push | Disabled | + +This prevents: -1. Build packages for all platforms -2. Sign and notarize the macOS build (if secrets are configured) -3. Create a draft GitHub release with all artifacts +- Manual tags outside the release flow +- Force-pushing tags (rewriting release history) +- Tags that don't follow SemVer ## Required Secrets diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 00000000..dd43662d --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,59 @@ +name: Auto Tag & Sync + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + tag-and-sync: + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Read version from package.json + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Check tag doesn't already exist + id: check + env: + TAG_VERSION: ${{ steps.version.outputs.version }} + run: | + if git rev-parse "v${TAG_VERSION}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + env: + TAG_NAME: ${{ steps.version.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG_NAME}" -m "Release ${TAG_NAME}" + git push origin "${TAG_NAME}" + + - name: Merge main → develop + run: | + git fetch origin develop + git checkout develop + git merge main --no-edit + git push origin develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6ab1438..05e05687 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,32 @@ permissions: contents: write jobs: + # ── Validate tag matches package.json ───────── + validate: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate tag matches package.json + env: + GIT_REF: ${{ github.ref }} + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION=${GIT_REF#refs/tags/v} + + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag version (${TAG_VERSION}) does not match package.json (${PACKAGE_VERSION})" + exit 1 + fi + + echo "✓ Tag v${TAG_VERSION} matches package.json ${PACKAGE_VERSION}" + + # ── Build for all platforms ─────────────────── build: + needs: [validate] + if: always() && (needs.validate.result == 'success' || needs.validate.result == 'skipped') strategy: matrix: include: diff --git a/.gitignore b/.gitignore index cd8f41ae..96f7a24c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ npm-debug.log* # Claude cache (keep plans) .claude/plugins/ .claude/statsig/ +.vercel diff --git a/CHANGELOG.md b/CHANGELOG.md index cf07bd2c..e9f7eb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.0] - 2026-03-13 + +### Added + +#### Website + +- Full website redesign with shadcn/ui + Magic UI + +#### Auth + +- Auth UX rethink with Enable Sync flow +- Auth middleware fix: return 401 instead of 500 on invalid tokens + +#### Sync + +- Error propagation for sync failures +- Exponential backoff on retry +- Abort sync on logout +- Typed token refresh +- Sync onboarding prompt after 5 notes +- Offline queue visibility in the UI + +#### AI Commands (Cmd+K v1) + +- Command panel for AI interactions +- AI settings configuration +- Keybindings for AI commands + +#### AI Knowledge (Cmd+K v2) + +- RAG-based knowledge retrieval +- Ask Notes: query your own notes with AI +- Related context suggestions + +#### AI Extensibility + +- Plugin API for AI commands +- Presets import/export + +#### Documentation + +- API documentation + +--- + ## [0.1.2] - 2026-01-01 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..d6b4b4cd --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,476 @@ +# Readied --- Product Roadmap + +> **Last updated:** 2026-03-13 +> **Current version:** v0.9.0 +> **Status:** Phases 1--5 complete, Phase 6+ in planning + +--- + +## Vision + +Readied is a **Markdown-first thinking workspace for builders**. Raycast meets Obsidian meets lightweight IDE for ideas. + +Your notes survive the app. Your thinking stays yours. AI augments, never replaces. + +--- + +## Core Principles + +| # | Principle | What it means | +| --- | ------------------------ | -------------------------------------------------------------------------------- | +| 1 | **Markdown-first** | Users own their data. Plain `.md` files, git-friendly, export = exact copy. | +| 2 | **Local-first** | Fast, offline, no cloud dependency. Sync is optional, never required. | +| 3 | **Command-driven UX** | Raycast-like Cmd+K, keyboard-first. Every action is a command. | +| 4 | **AI as augmentation** | AI operates ON notes --- summarize, expand, extract. It never replaces thinking. | +| 5 | **Speed and minimalism** | Developer-grade ergonomics. Start fast, stay fast, no bloat. | + +--- + +## What's Done (v0.9.0) + +### Phase 1: Foundation + +- Website redesign with CodeRabbit review fixes +- Auth UX rethink: "Enable Sync" modal, deep link race condition fix +- Auth middleware hardening (all JWT error types return 401 with JSON) +- Full API documentation (24 endpoints) + +### Phase 2: Sync Stability + +- Auto-sync error propagation to renderer via IPC events +- Exponential backoff on repeated failures (capped at 5 minutes) +- 401 auto-stops sync and emits `auth-expired` event +- Abort in-flight sync on logout via AbortController +- Token refresh with typed errors (expired / network / device_limit) +- Sync onboarding flow (prompt after 5 notes, session-dismissable) +- Offline queue visibility in status bar (pending count + offline indicator) + +### Phase 3: AI Commands (Cmd+K v1) + +- Cmd+K opens AI panel; insert-link remapped to Cmd+Shift+K +- AI command definitions: `ai:toggle-panel`, `ai:summarize`, `ai:rewrite`, `ai:tweet`, `ai:ask-notes` +- AI Settings: API key, model selector, max context notes +- Settings schema v2 with migration from v1 +- Escape cascade: palette -> AI panel -> graph -> search +- Existing `ai-assistant` package (Claude client, RAG, prompts) wired into panel + +### Phase 4: AI Knowledge (Cmd+K v2) --- Pending + +- RAG integration: query across notes +- "Ask Your Notes" command with knowledge context +- Related notes context in AI prompts + +### Phase 5: Extensibility --- Pending + +- Plugin API for custom AI commands (`registerAiCommand` on PluginContext) +- AI command presets (shareable JSON bundles with validation) +- Import/export AI command definitions +- Community command marketplace concept + +### Infrastructure + +| Component | Technology | +| ---------------- | -------------------------------- | +| Runtime | Electron + electron-vite | +| Frontend | React + TanStack Query + Zustand | +| Editor | CodeMirror 6 | +| Database | SQLite (better-sqlite3) | +| Monorepo | pnpm + turborepo | +| Backend API | Cloudflare Workers | +| Auth | Magic link (Resend) + JWT | +| Database (cloud) | Turso (libsql) | + +--- + +## Phase 6: Markdown Workspace Enhancement + +> **Goal:** Make Readied feel like a real workspace, not a single-note editor. + +| Feature | Description | Priority | +| ------------------------ | ---------------------------------------------------------- | -------- | +| File tree | Sidebar with workspace folders, drag-and-drop organization | High | +| Quick Open | Fuzzy file finder (Cmd+P) across all notes | High | +| Backlinks graph v2 | Improved graph visualization, filtering, zoom-to-node | Medium | +| Tags system v2 | Tag hierarchy, tag autocomplete, tag-based navigation | Medium | +| Git-friendly structure | Option to store notes as `.md` files on disk (vault mode) | Medium | +| Obsidian-like navigation | Forward/back history, breadcrumbs, link preview on hover | Low | + +**Key decisions:** + +- Vault mode (files on disk) is opt-in; SQLite remains the default for performance +- Quick Open reuses the command palette infrastructure (same fuzzy matching engine) +- Tags are derived from markdown frontmatter and inline `#tag` syntax + +--- + +## Phase 7: Command Palette Evolution (Cmd+K v3) + +> **Goal:** Make the command palette the Raycast of note-taking --- fast, extensible, context-aware. + +| Feature | Description | Priority | +| -------------------------- | ----------------------------------------------------------------- | -------- | +| Raycast-style architecture | Typed results, inline previews, action chains | High | +| Plugin-style commands | Community commands via plugin API | High | +| Command discovery UX | Categories, recent commands, favorites, frequency sorting | Medium | +| Contextual commands | Different commands based on selection, note type, cursor position | Medium | +| Template insertion | Insert note templates, code snippets, date patterns via commands | Medium | +| Quick actions | Create note, open note, search, insert template --- all sub-50ms | Low | + +**Command categories:** + +``` +Navigation ai editor notes + open-note summarize bold create + open-folder rewrite heading delete + switch-tab tweet link pin + search ask-notes code-block move +``` + +**Extensibility model:** + +- Commands are registered via `PluginContext.registerCommand()` +- AI commands use `PluginContext.registerAiCommand()` with template placeholders +- Presets are shareable JSON bundles (`AiCommandPreset` type with validation) + +--- + +## Phase 8: AI Workflows on Notes + +> **Goal:** Turn notes into action with a pipeline model: Note -> Action -> Result. + +| Feature | Description | Priority | +| ----------------------- | ------------------------------------------------------------------- | -------- | +| Action pipeline | Note -> Action -> Result with configurable output targets | High | +| Built-in actions | Summarize, expand, tweet thread, blog draft, explain, extract tasks | High | +| Output targets | Insert in note, clipboard, side panel, new note | High | +| Streaming responses | Token-by-token rendering in AI panel | Medium | +| Action chaining | Summarize -> Tweet -> Copy (multi-step workflows) | Medium | +| Custom workflow builder | Visual workflow editor for composing action chains | Low | + +**Output target model:** + +``` +outputTarget: 'replace' -- Replace selected text in editor + | 'insert' -- Insert at cursor position + | 'panel' -- Show in AI panel (chat) + | 'clipboard' -- Copy to clipboard + | 'new-note' -- Create a new note with result +``` + +**Built-in action presets:** + +| Action | Input | Output | Use case | +| ------------- | --------- | --------- | ------------------------------------ | +| Summarize | Full note | Panel | Quick overview of long notes | +| Expand | Selection | Replace | Flesh out bullet points | +| Tweet thread | Full note | Clipboard | Content repurposing | +| Blog draft | Selection | New note | Turn rough ideas into posts | +| Extract tasks | Full note | Insert | Pull action items from meeting notes | +| Explain | Selection | Panel | Clarify technical content | + +--- + +## Phase 9: Knowledge Retrieval (Embeddings) + +> **Goal:** "What did I write about X?" --- semantic search across all notes. + +| Feature | Description | Priority | +| -------------------- | ---------------------------------------------------- | -------- | +| Markdown chunking | Heading-based and block-based splitting | High | +| Embedding generation | Local (transformers.js) or API-based | High | +| Vector storage | SQLite with vec extension or dedicated store | High | +| Semantic search | Query all notes by meaning, not just keywords | High | +| Prompt composition | Inject retrieved context into AI prompts | Medium | +| Index management | On-save indexing, background reindex, manual rebuild | Medium | + +**Chunking strategy:** + +``` +Document: "# Architecture\n\nWe use React...\n\n## State\n\nZustand for UI..." + +Chunks: + [1] heading="Architecture" content="We use React..." + [2] heading="State" content="Zustand for UI..." +``` + +**Embedding options under evaluation:** + +| Option | Pros | Cons | +| ----------------------- | ----------------------------- | --------------------------------- | +| transformers.js (local) | Offline, private, no API cost | Model size (~50MB), slower on CPU | +| OpenAI API | High quality, fast | Requires internet, API cost | +| Local ONNX model | Good balance, offline | Setup complexity | + +**Indexing lifecycle:** + +``` +Note saved -> chunk -> embed -> store vectors +Query -> embed query -> cosine similarity -> top-k chunks -> compose prompt +``` + +--- + +## Phase 10: AI Assistant Panel v2 + +> **Goal:** A conversational AI that knows your notes and remembers your questions. + +| Feature | Description | Priority | +| ------------------------ | ------------------------------------------------------------- | -------- | +| Conversation memory | Persist chat history across sessions | High | +| Context injection | Current note, selected text, search results as context | High | +| Multi-turn conversations | Follow-up questions with note awareness | Medium | +| Side panel UX v2 | Resizable panel, markdown rendering, code highlighting | Medium | +| Quick actions from chat | Insert response into note, create new note, copy to clipboard | Medium | +| Conversation management | Name, search, delete past conversations | Low | + +**Context injection model:** + +``` +System prompt + + Conversation history (last N turns) + + Current note content (if open) + + Selected text (if any) + + RAG results (if knowledge query) + = Final prompt to LLM +``` + +--- + +## Phase 11: Onboarding Experience + +> **Goal:** Value within 2 minutes. + +| Step | What happens | Time | +| ---- | ------------------------------------------------------------- | ---- | +| 1 | Welcome screen: "Your thinking workspace" | 10s | +| 2 | Create first note (pre-filled template with instructions) | 30s | +| 3 | Command palette tutorial (Cmd+K highlight) | 20s | +| 4 | First AI action: summarize or generate tweet from sample note | 30s | +| 5 | Knowledge search demo: "Ask your notes" with seeded content | 20s | +| 6 | Done: workspace ready, user understands core loop | 10s | + +**Principles:** + +- No account required to start +- No configuration required to start +- AI features work with a single API key entry +- Every step is skippable +- Progress persists if interrupted + +--- + +## Phase 12: Developer Workflows + +> **Goal:** Make Readied the best place to think about code, not write code. + +| Feature | Description | Priority | +| ----------------------------- | -------------------------------------------------------- | -------- | +| Code snippet management | Syntax-highlighted snippets with language detection | Medium | +| Technical doc templates | RFC, ADR, API doc, runbook templates | Medium | +| API documentation generator | AI-powered: paste endpoint -> generate docs | Low | +| Meeting notes -> action items | AI extraction of tasks, decisions, owners | Medium | +| Decision log templates | Lightweight ADR format with status tracking | Low | +| Architecture Decision Records | Structured template with context, decision, consequences | Low | + +**Template examples:** + +```markdown +# ADR-001: [Decision Title] + +- **Status:** Proposed | Accepted | Deprecated +- **Date:** {{date}} +- **Context:** Why is this decision needed? +- **Decision:** What was decided? +- **Consequences:** What are the trade-offs? +``` + +--- + +## Technical Architecture + +```mermaid +graph TB + subgraph Electron + direction TB + subgraph Main Process + SQLite[(SQLite DB)] + IPC[IPC Handlers] + Sync[Sync Service] + Auth[Auth Service] + end + + subgraph Renderer + React[React UI] + CM6[CodeMirror 6] + CmdPalette[Command Palette] + AIPanel[AI Panel] + Zustand[Zustand Stores] + TQ[TanStack Query] + end + + subgraph Preload + Bridge[Secure IPC Bridge] + end + end + + subgraph Packages + Core[core
Domain logic + MD parsing] + StorageCore[storage-core
Storage interfaces] + StorageSQLite[storage-sqlite
SQLite adapter] + CmdRegistry[command-registry
Command definitions] + AiAssistant[ai-assistant
Claude client + RAG] + PluginAPI[plugin-api
Plugin system] + SyncCore[sync-core
Sync logic] + Licensing[licensing
License validation] + ProductConfig[product-config
Plans + pricing] + DesignSystem[design-system
Tokens + themes] + Wikilinks[wikilinks
Link parsing] + Embeds[embeds
Embed handling] + Tasks[tasks
Task extraction] + Commands[commands
Command implementations] + PluginCLI[plugin-cli
Plugin scaffolding] + end + + subgraph Cloud + API[Cloudflare Workers API] + Turso[(Turso DB)] + Resend[Resend Email] + end + + React --> Bridge --> IPC + IPC --> SQLite + IPC --> Core + Core --> StorageCore + StorageSQLite --> StorageCore + AIPanel --> AiAssistant + CmdPalette --> CmdRegistry + React --> PluginAPI + Sync --> SyncCore + Sync --> API + Auth --> API + API --> Turso + API --> Resend +``` + +### Package Dependency Graph + +```mermaid +graph LR + desktop[apps/desktop] --> core + desktop --> storage-sqlite + desktop --> command-registry + desktop --> ai-assistant + desktop --> plugin-api + desktop --> sync-core + desktop --> licensing + desktop --> product-config + + storage-sqlite --> storage-core + ai-assistant --> core + command-registry --> core + plugin-api --> core + sync-core --> core + commands --> command-registry + commands --> core +``` + +### AI Pipeline + +```mermaid +flowchart LR + Input[User Input
selection / note / query] --> Template[Template Resolution
{{selection}} {{note}} {{title}}] + Template --> Context[Context Assembly
system prompt + user prompt + RAG] + Context --> LLM[LLM API
Claude / OpenAI] + LLM --> Output[Output Routing
replace / insert / panel / clipboard] +``` + +--- + +## Release Strategy + +### Versioning + +Readied follows [Semantic Versioning](https://semver.org/): + +| Version bump | When | Example | +| ----------------- | --------------------------------- | -------------------------- | +| **Major** (X.0.0) | Breaking changes, major UX shifts | v1.0.0: public launch | +| **Minor** (0.X.0) | New features, non-breaking | v0.10.0: workspace folders | +| **Patch** (0.0.X) | Bug fixes, polish | v0.9.1: fix sync edge case | + +### Branch Strategy (Git Flow) + +``` +main <-- Production releases only + \-- develop <-- Integration branch + \-- feature/* <-- New features + \-- fix/* <-- Bug fixes + \-- release/* <-- Release preparation +``` + +### Release Cadence + +| Channel | Frequency | Audience | +| ----------- | ---------- | ----------------------- | +| **Stable** | Monthly | All users | +| **Beta** | Bi-weekly | Early adopters (opt-in) | +| **Nightly** | Daily (CI) | Contributors only | + +### Release Checklist + +- [ ] All tests pass (`pnpm test`) +- [ ] Build succeeds (`pnpm build`) +- [ ] TypeScript clean (`pnpm typecheck`) +- [ ] Changelog updated +- [ ] Version bumped in `package.json` +- [ ] Release branch merged to `main` and back to `develop` +- [ ] macOS build signed and notarized +- [ ] Release notes published on GitHub + +--- + +## Milestone Timeline + +```mermaid +gantt + title Readied Roadmap + dateFormat YYYY-MM + axisFormat %b %Y + + section Done + Phase 1 - Foundation :done, p1, 2026-01, 2026-02 + Phase 2 - Sync Stability :done, p2, 2026-02, 2026-03 + Phase 3 - AI Commands v1 :done, p3, 2026-03, 2026-03 + + section In Progress + Phase 4 - AI Knowledge v2 :active, p4, 2026-03, 2026-04 + Phase 5 - Extensibility :active, p5, 2026-03, 2026-04 + + section Planned + Phase 6 - Workspace :p6, 2026-04, 2026-05 + Phase 7 - Cmd+K v3 :p7, 2026-05, 2026-06 + Phase 8 - AI Workflows :p8, 2026-06, 2026-07 + Phase 9 - Embeddings :p9, 2026-07, 2026-08 + Phase 10 - AI Panel v2 :p10, 2026-08, 2026-09 + Phase 11 - Onboarding :p11, 2026-09, 2026-09 + Phase 12 - Dev Workflows :p12, 2026-09, 2026-10 + + section Launch + v1.0.0 Public Launch :milestone, launch, 2026-10, 0d +``` + +--- + +## Contributing + +Readied is built in the open. If you want to contribute: + +1. Check the [issues](https://github.com/tomymaritano/readide/issues) for `good first issue` labels +2. Read `CLAUDE.md` for project conventions +3. Read `plan.md` for architecture decisions +4. Branch from `develop`, target PRs to `develop` +5. Follow conventional commits (`feat:`, `fix:`, `refactor:`, etc.) + +--- + +_This roadmap is a living document. Priorities shift based on user feedback and technical discovery. Last major update: 2026-03-13._ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f58d2c20..61792b6b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@readied/desktop", - "version": "0.8.0", + "version": "0.9.0", "private": true, "description": "Markdown-first, offline-forever note app for developers", "author": { diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 910a1c26..109b416f 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -186,7 +186,12 @@ export class ApiClient { /** * Generic HTTP request with auth, retry, and error handling */ - private async request(endpoint: string, options: RequestInit = {}, retries = 3): Promise { + private async request( + endpoint: string, + options: RequestInit = {}, + retries = 3, + _isAuthRetry = false + ): Promise { const url = `${this.baseURL}${endpoint}`; // Inject access token if available @@ -212,11 +217,11 @@ export class ApiClient { }); // Handle 401 - Token expired - if (response.status === 401 && tokens) { + if (response.status === 401 && tokens && !_isAuthRetry) { const refreshResult = await this.refreshAccessToken(); switch (refreshResult.type) { case 'success': - return this.request(endpoint, options, 0); + return this.request(endpoint, options, 0, true); case 'network': // Transient failure — throw retryable error so caller can try later throw new ApiError(0, refreshResult.message ?? 'Network error during token refresh'); @@ -233,11 +238,7 @@ export class ApiClient { refreshResult.message ?? 'Session expired. Please sign in again.' ); default: - await this.tokenStorage.clearTokens(); - throw new ApiError( - 401, - refreshResult.message ?? 'Authentication failed. Please sign in again.' - ); + throw new ApiError(0, refreshResult.message ?? 'Transient error during token refresh'); } } diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index c054aa49..fc8a479f 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -16,7 +16,13 @@ import { createTimestamp, type NoteStatus, } from '@readied/core'; -import type { ApiClient, SyncChange, NotebookSyncChange, NotebookPushResult } from './apiClient.js'; +import { + ApiError, + type ApiClient, + type SyncChange, + type NotebookSyncChange, + type NotebookPushResult, +} from './apiClient.js'; import type { EncryptionService } from './encryptionService.js'; // ============================================================================ @@ -85,9 +91,7 @@ function isNetworkError(error: unknown): boolean { * Check if an error represents a 401 Unauthorized response. */ function isAuthError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const msg = error.message.toLowerCase(); - return msg.includes('401') || msg.includes('unauthorized'); + return error instanceof ApiError && error.statusCode === 401; } // ============================================================================ @@ -634,6 +638,31 @@ export class SyncService { }; } catch (error) { const bandwidth = this.apiClient.getBandwidth(); + + // Intentional abort (e.g. logout / stopAutoSync) — not a real error + if (error instanceof Error && error.message === 'Sync aborted') { + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { + notesPulled, + notesPushed, + notebooksPulled, + notebooksPushed, + tagsPulled, + tagsPushed, + conflicts: totalConflicts, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + errorMessage: 'Sync aborted', + }); + + return { + success: false, + changesApplied: 0, + changesPushed: 0, + conflicts: [], + error: 'Sync aborted', + }; + } + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { notesPulled: 0, notesPushed: 0, diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index d1c532f6..4e1cfa61 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -13,6 +13,16 @@ import { } from '@readied/plugin-api'; import type { EditorAPIWithEvents, AppAPIWithEvents, DataAPIWithEvents } from '@readied/plugin-api'; import type { RegisteredCommand } from '@readied/command-registry'; +import type { AiPanelMode } from '@readied/ai-assistant'; +import { + SUMMARIZE_SYSTEM_PROMPT, + SUMMARIZE_USER_TEMPLATE, + REWRITE_SYSTEM_PROMPT, + REWRITE_USER_TEMPLATE, + TWEET_SYSTEM_PROMPT, + TWEET_USER_TEMPLATE, + resolveTemplate, +} from '@readied/ai-assistant'; import { useStore } from 'zustand'; import type { NoteSnapshot, NoteStatus } from '../preload/index'; import { NoteList } from './components/NoteList'; @@ -22,7 +32,7 @@ import { Sidebar } from './components/sidebar'; import { GraphView } from './components/GraphView'; import { CommandPalette } from './components/CommandPalette'; import { AiPanel } from './components/ai/AiPanel'; -import type { AiPanelMode } from '@readied/ai-assistant'; +import type { AiInitialCommand } from './components/ai/AiPanel'; import { LicenseProvider } from './contexts/LicenseContext'; import { ToastProvider, useToast } from './components/Toast'; import type { PluginLoadError } from './stores/pluginRuntimeStore'; @@ -43,6 +53,7 @@ import { useDebouncedSearch } from './hooks/useDebouncedSearch'; import { useCommandKeybindings } from './hooks/useCommandKeybindings'; import { useRegisterAppCommands } from './hooks/useRegisterAppCommands'; import { useRegisterAiCommands } from './hooks/useRegisterAiCommands'; +import { useRegisterPluginAiCommands } from './hooks/useRegisterPluginAiCommands'; import { getEditorView, registry as commandRegistry } from './hooks/useCommandRegistry'; import { builtInPlugins } from './plugins'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; @@ -173,6 +184,7 @@ function NotesApp() { const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isAiPanelOpen, setIsAiPanelOpen] = useState(false); const [aiPanelMode, setAiPanelMode] = useState('chat'); + const [pendingAiCommand, setPendingAiCommand] = useState(null); // Plugin system: create stable EditorAPI and AppAPI (early, so handlers can reference them) const editorAPI = useMemo(() => createEditorAPI(getEditorView), []); @@ -630,11 +642,71 @@ function NotesApp() { setIsAiPanelOpen(true); }, []); + /** Helper: get selection text from editor */ + const getSelectionText = useCallback(() => { + const view = getEditorView(); + if (!view) return ''; + const { from, to } = view.state.selection.main; + return view.state.sliceDoc(from, to); + }, []); + + /** Helper: replace selection in editor */ + const aiReplaceSelection = useCallback((text: string) => { + const view = getEditorView(); + if (!view) return; + const { from, to } = view.state.selection.main; + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + view.focus(); + }, []); + + /** Build and dispatch an AI command with given prompts and output target */ + const dispatchAiCommand = useCallback( + (systemPrompt: string, userTemplate: string, outputTarget: 'replace' | 'insert' | 'panel') => { + const selection = getSelectionText(); + if (!selection) return; // Nothing selected — no-op + const userPrompt = resolveTemplate(userTemplate, { selection }); + setPendingAiCommand({ systemPrompt, userPrompt, outputTarget }); + setAiPanelMode('chat'); + setIsAiPanelOpen(true); + }, + [getSelectionText] + ); + + const handleSummarize = useCallback(() => { + dispatchAiCommand(SUMMARIZE_SYSTEM_PROMPT, SUMMARIZE_USER_TEMPLATE, 'panel'); + }, [dispatchAiCommand]); + + const handleRewrite = useCallback(() => { + dispatchAiCommand(REWRITE_SYSTEM_PROMPT, REWRITE_USER_TEMPLATE, 'replace'); + }, [dispatchAiCommand]); + + const handleTweet = useCallback(() => { + dispatchAiCommand(TWEET_SYSTEM_PROMPT, TWEET_USER_TEMPLATE, 'panel'); + }, [dispatchAiCommand]); + + const clearPendingAiCommand = useCallback(() => { + setPendingAiCommand(null); + }, []); + useRegisterAiCommands({ onTogglePanel: toggleAiPanel, onAskNotes: openAskNotes, + onSummarize: handleSummarize, + onRewrite: handleRewrite, + onTweet: handleTweet, }); + // Bridge: plugin-registered AI commands → command palette → AI panel + const handlePluginAiCommand = useCallback((command: AiInitialCommand) => { + setPendingAiCommand(command); + setAiPanelMode('chat'); + setIsAiPanelOpen(true); + }, []); + useRegisterPluginAiCommands(handlePluginAiCommand); + // AI Panel callbacks — wired to existing app state const aiConfigCache = useRef>({}); @@ -827,6 +899,9 @@ function NotesApp() { getConfig={aiGetConfig} insertAtCursor={aiInsertAtCursor} initialMode={aiPanelMode} + initialCommand={pendingAiCommand} + replaceSelection={aiReplaceSelection} + onCommandExecuted={clearPendingAiCommand} /> )} diff --git a/apps/desktop/src/renderer/components/ai/AiPanel.tsx b/apps/desktop/src/renderer/components/ai/AiPanel.tsx index 9a14c69a..c7dc47b4 100644 --- a/apps/desktop/src/renderer/components/ai/AiPanel.tsx +++ b/apps/desktop/src/renderer/components/ai/AiPanel.tsx @@ -5,6 +5,13 @@ import type { ClaudeMessage, NoteContext, AiPanelMode } from '@readied/ai-assist import { useSettingsStore, selectAi } from '../../stores/settings'; import { AiMessage } from './AiMessage'; +/** Pre-filled command to auto-execute on mount (used by ai:summarize, ai:rewrite, ai:tweet) */ +export interface AiInitialCommand { + systemPrompt: string; + userPrompt: string; + outputTarget: 'replace' | 'insert' | 'panel'; +} + interface AiPanelProps { onClose: () => void; getCurrentNote: () => { id: string; title: string; content: string } | null; @@ -14,6 +21,12 @@ interface AiPanelProps { insertAtCursor: (text: string) => void; /** Initial mode: 'chat' (default) or 'ask-notes' */ initialMode?: AiPanelMode; + /** Pre-filled command to auto-execute (skip input, go straight to Claude) */ + initialCommand?: AiInitialCommand | null; + /** Replace the current editor selection with text */ + replaceSelection?: (text: string) => void; + /** Callback to clear initialCommand after execution */ + onCommandExecuted?: () => void; } export function AiPanel({ @@ -24,6 +37,9 @@ export function AiPanel({ getConfig, insertAtCursor, initialMode = 'chat', + initialCommand = null, + replaceSelection, + onCommandExecuted, }: AiPanelProps) { const aiSettings = useSettingsStore(selectAi); const [messages, setMessages] = useState([]); @@ -50,21 +66,88 @@ export function AiPanel({ setMode(initialMode); }, [initialMode]); + // Auto-execute a pre-filled command (ai:summarize, ai:rewrite, ai:tweet) + useEffect(() => { + if (!initialCommand) return; + + const execute = async () => { + const aiSettings_ = useSettingsStore.getState().settings.ai; + const hasSettingsKey = Boolean(aiSettings_.apiKey); + const apiKey = hasSettingsKey ? aiSettings_.apiKey : getConfig('apiKey'); + if (!apiKey) { + setError('Please set your Anthropic API key in Settings > AI Assistant'); + onCommandExecuted?.(); + return; + } + + const model = hasSettingsKey + ? aiSettings_.model + : getConfig('model') || 'claude-sonnet-4-20250514'; + + // Show user message in chat + const userMsg: ClaudeMessage = { role: 'user', content: initialCommand.userPrompt }; + setMessages(prev => [...prev, userMsg]); + setLoading(true); + setError(null); + + try { + const result = await window.readied.ai.query({ + apiKey, + model, + system: initialCommand.systemPrompt, + messages: [userMsg], + maxTokens: 2048, + }); + + if (result.ok) { + const responseText = result.content; + + if (initialCommand.outputTarget === 'replace' && replaceSelection) { + replaceSelection(responseText); + setMessages(prev => [ + ...prev, + { role: 'assistant', content: responseText + '\n\n*(Selection replaced in editor)*' }, + ]); + } else if (initialCommand.outputTarget === 'insert') { + insertAtCursor(responseText); + setMessages(prev => [ + ...prev, + { role: 'assistant', content: responseText + '\n\n*(Inserted into editor)*' }, + ]); + } else { + // 'panel' — just show in chat + setMessages(prev => [...prev, { role: 'assistant', content: responseText }]); + } + } else { + setError(result.error); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + onCommandExecuted?.(); + } + }; + + execute(); + }, [initialCommand]); // intentionally depends only on initialCommand + const handleSubmit = useCallback(async () => { const query = input.trim(); if (!query || loading) return; // Prefer settings store, fall back to plugin config for backwards compatibility - const apiKey = aiSettings.apiKey || getConfig('apiKey'); + const hasSettingsKey = Boolean(aiSettings.apiKey); + const apiKey = hasSettingsKey ? aiSettings.apiKey : getConfig('apiKey'); if (!apiKey) { setError('Please set your Anthropic API key in Settings > AI Assistant'); return; } - const model = aiSettings.apiKey + const model = hasSettingsKey ? aiSettings.model : getConfig('model') || 'claude-sonnet-4-20250514'; - const maxContextNotes = aiSettings.apiKey + const maxContextNotes = hasSettingsKey ? aiSettings.maxContextNotes : getConfig('maxContextNotes') || 5; diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index 258c891e..c0fdcd68 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -152,10 +152,19 @@ export function Sidebar({ onOpenGraph }: SidebarProps) {
Sync your notes across devices
- -
diff --git a/apps/desktop/src/renderer/components/sync/LoginModal.tsx b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx similarity index 98% rename from apps/desktop/src/renderer/components/sync/LoginModal.tsx rename to apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx index c5c40783..2999b78b 100644 --- a/apps/desktop/src/renderer/components/sync/LoginModal.tsx +++ b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx @@ -59,10 +59,12 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { if (!isOpen) { // Delay reset so close animation can play const timeout = setTimeout(() => { - setStep('value-prop'); - setEmail(''); - setError(null); - setResendTimer(0); + if (!isOpen) { + setStep('value-prop'); + setEmail(''); + setError(null); + setResendTimer(0); + } }, 200); return () => clearTimeout(timeout); } diff --git a/apps/desktop/src/renderer/components/sync/index.ts b/apps/desktop/src/renderer/components/sync/index.ts index 008cc558..ff6cefad 100644 --- a/apps/desktop/src/renderer/components/sync/index.ts +++ b/apps/desktop/src/renderer/components/sync/index.ts @@ -6,4 +6,4 @@ export { SyncStatusIndicator } from './SyncStatusIndicator'; export { ConflictResolver } from './ConflictResolver'; -export { EnableSyncModal } from './LoginModal'; +export { EnableSyncModal } from './EnableSyncModal'; diff --git a/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts index 5ce462b1..14b628f3 100644 --- a/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts +++ b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts @@ -5,10 +5,13 @@ import { registry } from './useCommandRegistry'; interface AiCommandHandlers { onTogglePanel: () => void; onAskNotes: () => void; + onSummarize: () => void; + onRewrite: () => void; + onTweet: () => void; } /** - * Register AI-related commands (toggle panel, ask-notes, etc.) + * Register AI-related commands (toggle panel, ask-notes, summarize, rewrite, tweet) * Follows the same pattern as useRegisterAppCommands. */ export function useRegisterAiCommands(handlers: AiCommandHandlers): void { @@ -19,6 +22,9 @@ export function useRegisterAiCommands(handlers: AiCommandHandlers): void { const executors: Record void> = { 'ai:toggle-panel': () => handlersRef.current.onTogglePanel(), 'ai:ask-notes': () => handlersRef.current.onAskNotes(), + 'ai:summarize': () => handlersRef.current.onSummarize(), + 'ai:rewrite': () => handlersRef.current.onRewrite(), + 'ai:tweet': () => handlersRef.current.onTweet(), }; const unregisters: Array<() => void> = []; diff --git a/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts new file mode 100644 index 00000000..b497c9c5 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react'; +import { aiCommandStore } from '@readied/plugin-api'; +import type { AiCommandRegistration } from '@readied/plugin-api'; +import { resolveTemplate } from '@readied/ai-assistant'; +import type { AiInitialCommand } from '../components/ai/AiPanel'; +import { registry, getEditorView } from './useCommandRegistry'; + +/** + * Callback invoked when a plugin AI command is executed from the palette. + * Receives a fully resolved AiInitialCommand ready for the AI panel. + */ +export interface PluginAiCommandExecutor { + (command: AiInitialCommand): void; +} + +/** + * Bridge between the plugin AI command store and the command palette. + * + * Subscribes to `aiCommandStore` (Zustand vanilla) and dynamically + * registers/unregisters commands in the `CommandRegistry` so they + * appear in the command palette. + * + * When a plugin AI command is executed: + * 1. Gets editor selection, note content, and title + * 2. Resolves the template using `resolveTemplate()` + * 3. Calls the executor callback to open the AI panel with the resolved command + */ +export function useRegisterPluginAiCommands(onExecute: PluginAiCommandExecutor): void { + const onExecuteRef = useRef(onExecute); + onExecuteRef.current = onExecute; + + useEffect(() => { + // Track unregister functions keyed by registration id + const unregisterMap = new Map void>(); + + function registerCommand(reg: AiCommandRegistration): void { + // Guard against double-registration + if (unregisterMap.has(reg.id)) return; + + const unregister = registry.register({ + id: `plugin-ai:${reg.id}`, + name: `AI: ${reg.name}`, + description: reg.description, + category: 'ai', + context: 'editor', + showInPalette: true, + icon: reg.icon, + execute: () => { + // Gather context from editor + const view = getEditorView(); + let selection = ''; + let note = ''; + let title = ''; + + if (view) { + const state = view.state; + const sel = state.selection.main; + selection = state.sliceDoc(sel.from, sel.to); + note = state.doc.toString(); + + // Extract title from first heading line + const firstLine = state.doc.lineAt(1).text; + if (firstLine.startsWith('# ')) { + title = firstLine.slice(2).trim(); + } + } + + // Resolve template placeholders + const userPrompt = resolveTemplate(reg.userPromptTemplate, { + selection, + note, + title, + }); + + // Dispatch to AI panel via callback + onExecuteRef.current({ + systemPrompt: reg.systemPrompt, + userPrompt, + outputTarget: reg.outputTarget ?? 'panel', + }); + + return true; + }, + }); + + unregisterMap.set(reg.id, unregister); + } + + function unregisterCommand(id: string): void { + const unregister = unregisterMap.get(id); + if (unregister) { + unregister(); + unregisterMap.delete(id); + } + } + + function syncRegistrations(registrations: AiCommandRegistration[]): void { + const currentIds = new Set(registrations.map(r => r.id)); + + // Remove commands no longer in the store + for (const id of unregisterMap.keys()) { + if (!currentIds.has(id)) { + unregisterCommand(id); + } + } + + // Add new commands + for (const reg of registrations) { + if (!unregisterMap.has(reg.id)) { + registerCommand(reg); + } + } + } + + // Initial sync with current store state + syncRegistrations(aiCommandStore.getState().registrations); + + // Subscribe to future changes + const unsubscribe = aiCommandStore.subscribe(state => { + syncRegistrations(state.registrations); + }); + + return () => { + unsubscribe(); + // Clean up all palette registrations + for (const unregister of unregisterMap.values()) { + unregister(); + } + unregisterMap.clear(); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/styles/ai-panel.css b/apps/desktop/src/renderer/styles/ai-panel.css index 4f42528c..4ae24659 100644 --- a/apps/desktop/src/renderer/styles/ai-panel.css +++ b/apps/desktop/src/renderer/styles/ai-panel.css @@ -34,7 +34,7 @@ font-size: 10px; font-weight: 500; color: var(--accent); - background: rgba(94, 234, 212, 0.1); + background: var(--accent-subtle); padding: 1px 6px; border-radius: 8px; white-space: nowrap; @@ -67,7 +67,7 @@ .ai-panel-btn.active { color: var(--accent); - background: rgba(94, 234, 212, 0.1); + background: var(--accent-subtle); } /* Messages area */ diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css index 049319a5..09b396e3 100644 --- a/apps/desktop/src/renderer/styles/global.css +++ b/apps/desktop/src/renderer/styles/global.css @@ -1511,7 +1511,7 @@ input:focus-visible { justify-content: space-between; padding: 8px 12px; margin: 0 8px 4px; - background: var(--accent-subtle, rgba(59, 130, 246, 0.08)); + background: var(--accent-subtle, rgba(94, 234, 212, 0.08)); border-radius: var(--radius-sm, 4px); font-size: var(--text-xs); color: var(--text-secondary); @@ -1526,7 +1526,7 @@ input:focus-visible { margin-left: 8px; } -.sidebar-sync-prompt-actions button:first-child { +.sidebar-sync-prompt-actions .sidebar-sync-prompt-enable { background: var(--accent); color: white; border: none; @@ -1537,11 +1537,11 @@ input:focus-visible { font-weight: 500; } -.sidebar-sync-prompt-actions button:first-child:hover { +.sidebar-sync-prompt-actions .sidebar-sync-prompt-enable:hover { opacity: 0.9; } -.sidebar-sync-prompt-actions button:last-child { +.sidebar-sync-prompt-actions .sidebar-sync-prompt-dismiss { background: none; border: none; color: var(--text-muted); @@ -1551,7 +1551,7 @@ input:focus-visible { line-height: 1; } -.sidebar-sync-prompt-actions button:last-child:hover { +.sidebar-sync-prompt-actions .sidebar-sync-prompt-dismiss:hover { color: var(--text-secondary); } diff --git a/apps/desktop/src/renderer/styles/tokens.css b/apps/desktop/src/renderer/styles/tokens.css index 59830963..49933a05 100644 --- a/apps/desktop/src/renderer/styles/tokens.css +++ b/apps/desktop/src/renderer/styles/tokens.css @@ -36,6 +36,7 @@ /* ===== COLORS - Accent ===== */ --accent: #5eead4; --accent-muted: rgba(94, 234, 212, 0.15); + --accent-subtle: rgba(94, 234, 212, 0.1); --accent-strong: #2dd4bf; /* ===== COLORS - Semantic ===== */ @@ -138,6 +139,7 @@ /* Accent (lighter versions for light theme) */ --accent: #14b8a6; + --accent-subtle: rgba(20, 184, 166, 0.1); --accent-muted: rgba(20, 184, 166, 0.15); --accent-strong: #0d9488; --accent-primary: #14b8a6; diff --git a/apps/web/._mdx-components.tsx b/apps/web/._mdx-components.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/._mdx-components.tsx and /dev/null differ diff --git a/apps/web/._package.json b/apps/web/._package.json deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/._package.json and /dev/null differ diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f9ef312e..4039e06a 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -2,3 +2,4 @@ .source/ node_modules/ out/ +.vercel diff --git a/apps/web/app/(marketing)/._page.tsx b/apps/web/app/(marketing)/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx b/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx index 30b38ee9..2c2fc8d4 100644 --- a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx +++ b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx @@ -11,7 +11,7 @@ export default function AuthVerifyContent() { useEffect(() => { if (token) { - window.location.href = `readied://auth/verify?token=${token}`; + window.location.href = `readied://auth/verify?token=${encodeURIComponent(token)}`; const timer = setTimeout(() => { setShowFallback(true); @@ -39,8 +39,8 @@ export default function AuthVerifyContent() {
-

Invalid Link

-

+

Invalid Link

+

This verification link is incomplete or has expired. Please request a new magic link from the Readied app.

@@ -61,8 +61,8 @@ export default function AuthVerifyContent() { {!showFallback ? ( <>
-

Opening Readied...

-

The app should open automatically. Hang tight.

+

Opening Readied...

+

The app should open automatically. Hang tight.

) : ( <> @@ -79,27 +79,27 @@ export default function AuthVerifyContent() {
-

Almost there

-

+

Almost there

+

The app didn't open automatically. Try clicking the button below.

Open in Readied
-

+

Opened this on the wrong device?

-

+

Open this same link on the device where Readied is installed. The magic link is valid for 15 minutes.

-

+

Don't have Readied yet?{' '} Download now diff --git a/apps/web/app/(marketing)/changelog/._page.tsx b/apps/web/app/(marketing)/changelog/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/changelog/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/download/._page.tsx b/apps/web/app/(marketing)/download/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/download/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/faq/._page.tsx b/apps/web/app/(marketing)/faq/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/faq/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/philosophy/._page.tsx b/apps/web/app/(marketing)/philosophy/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/philosophy/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/plugins/._page.tsx b/apps/web/app/(marketing)/plugins/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/plugins/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/pricing/._page.tsx b/apps/web/app/(marketing)/pricing/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/pricing/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx index f381ca5e..adff55f9 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/(marketing)/pricing/page.tsx @@ -14,7 +14,6 @@ import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { BorderBeam } from '@/components/magicui/border-beam'; -import { NumberTicker } from '@/components/magicui/number-ticker'; import { Accordion, AccordionItem, @@ -27,9 +26,8 @@ export default function PricingPage() { const { plans, guarantees, trialDays, trialDescription } = config; const proPricing = plans.pro.pricing!; - // Extract numeric values from price labels for NumberTicker - const monthlyPrice = proPricing.intervals.monthly.amountCents / 100; - const annualPrice = proPricing.intervals.annual.amountCents / 100; + const monthlyLabel = proPricing.intervals.monthly.label; + const annualLabel = proPricing.intervals.annual.label; const faqs = [ { q: 'What if you stop developing Readied?', a: guarantees.freeTierForever.description }, @@ -109,27 +107,13 @@ export default function PricingPage() {

{plans.pro.name}
-
- $ - - /mo -
+ + {monthlyLabel} + or -
- $ - - - /year - -
+ + {annualLabel} + Save {proPricing.annualSavings} diff --git a/apps/web/app/(marketing)/privacy/._page.tsx b/apps/web/app/(marketing)/privacy/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/privacy/._page.tsx and /dev/null differ diff --git a/apps/web/app/(marketing)/terms/._page.tsx b/apps/web/app/(marketing)/terms/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/(marketing)/terms/._page.tsx and /dev/null differ diff --git a/apps/web/app/._globals.css b/apps/web/app/._globals.css deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/._globals.css and /dev/null differ diff --git a/apps/web/app/._layout.tsx b/apps/web/app/._layout.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/._layout.tsx and /dev/null differ diff --git a/apps/web/app/docs/._layout.tsx b/apps/web/app/docs/._layout.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/docs/._layout.tsx and /dev/null differ diff --git a/apps/web/app/docs/[[...slug]]/._page.tsx b/apps/web/app/docs/[[...slug]]/._page.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/app/docs/[[...slug]]/._page.tsx and /dev/null differ diff --git a/apps/web/app/docs/[[...slug]]/page.tsx b/apps/web/app/docs/[[...slug]]/page.tsx index 64a54b4d..4db6a23e 100644 --- a/apps/web/app/docs/[[...slug]]/page.tsx +++ b/apps/web/app/docs/[[...slug]]/page.tsx @@ -1,31 +1,9 @@ import { source } from '@/lib/source'; import { notFound } from 'next/navigation'; import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page'; -import defaultMdxComponents from 'fumadocs-ui/mdx'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Step, Steps } from 'fumadocs-ui/components/steps'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; -import { File, Folder, Files } from 'fumadocs-ui/components/files'; -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { useMDXComponents } from '@/mdx-components'; -const mdxComponents = { - ...defaultMdxComponents, - Card, - Cards, - Callout, - Step, - Steps, - Tab, - Tabs, - Accordion, - Accordions, - File, - Folder, - Files, - TypeTable, -}; +const mdxComponents = useMDXComponents({}); export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params; diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index 1bfcc343..ad924e52 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -1,7 +1,7 @@ +import type { ReactNode } from 'react'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import { source } from '@/lib/source'; import { baseOptions } from '@/lib/layout.shared'; -import type { ReactNode } from 'react'; export default function Layout({ children }: { children: ReactNode }) { return ( diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 4f6fafc2..1fb5c9e0 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -27,22 +27,39 @@ /* ─── Fumadocs theme alignment ─── */ /* Override fumadocs CSS variables to match our violet/zinc design system */ :root, .dark { + --rd-violet: #8b5cf6; + --rd-violet-light: #a78bfa; + --rd-violet-lighter: #c4b5fd; + --rd-violet-glow-10: rgba(139, 92, 246, 0.1); + --rd-violet-glow-12: rgba(139, 92, 246, 0.12); + --rd-violet-glow-20: rgba(139, 92, 246, 0.2); + --rd-violet-glow-30: rgba(139, 92, 246, 0.3); + --rd-violet-glow-08: rgba(139, 92, 246, 0.08); + --rd-surface: #111113; + --rd-inset: #0c0c0e; + --rd-foreground: #fafafa; + --rd-muted-foreground: #a1a1aa; + --rd-subtle-foreground: #e4e4e7; + --rd-faint: #52525b; + --rd-border-subtle: rgba(255, 255, 255, 0.06); + --rd-border: rgba(255, 255, 255, 0.08); + --color-fd-background: #09090b; - --color-fd-foreground: #fafafa; - --color-fd-muted: #111113; - --color-fd-muted-foreground: #a1a1aa; - --color-fd-popover: #111113; - --color-fd-popover-foreground: #e4e4e7; - --color-fd-card: #111113; - --color-fd-card-foreground: #fafafa; - --color-fd-border: rgba(255, 255, 255, 0.08); - --color-fd-primary: #8b5cf6; + --color-fd-foreground: var(--rd-foreground); + --color-fd-muted: var(--rd-surface); + --color-fd-muted-foreground: var(--rd-muted-foreground); + --color-fd-popover: var(--rd-surface); + --color-fd-popover-foreground: var(--rd-subtle-foreground); + --color-fd-card: var(--rd-surface); + --color-fd-card-foreground: var(--rd-foreground); + --color-fd-border: var(--rd-border); + --color-fd-primary: var(--rd-violet); --color-fd-primary-foreground: #ffffff; --color-fd-secondary: #1a1a1f; - --color-fd-secondary-foreground: #e4e4e7; - --color-fd-accent: rgba(139, 92, 246, 0.12); - --color-fd-accent-foreground: #e4e4e7; - --color-fd-ring: #8b5cf6; + --color-fd-secondary-foreground: var(--rd-subtle-foreground); + --color-fd-accent: var(--rd-violet-glow-12); + --color-fd-accent-foreground: var(--rd-subtle-foreground); + --color-fd-ring: var(--rd-violet); } /* ─── Fumadocs component overrides ─── */ @@ -54,26 +71,26 @@ /* Sidebar links — subtle hover */ .fd-sidebar [data-active='true'] { - color: #8b5cf6 !important; + color: var(--rd-violet) !important; } /* Docs nav bar — glass effect */ nav[data-fumadocs] { - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--rd-border-subtle); } /* Code blocks — darker with violet accents */ pre:has(code) { - background: #0c0c0e !important; - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--rd-inset) !important; + border: 1px solid var(--rd-border-subtle); border-radius: 0.75rem; } /* Inline code */ :not(pre) > code { - background: rgba(139, 92, 246, 0.1) !important; - color: #c4b5fd !important; - border: 1px solid rgba(139, 92, 246, 0.2); + background: var(--rd-violet-glow-10) !important; + color: var(--rd-violet-lighter) !important; + border: 1px solid var(--rd-violet-glow-20); border-radius: 0.375rem; padding: 0.125rem 0.375rem; font-size: 0.875em; @@ -81,51 +98,51 @@ pre:has(code) { /* Table of contents — active item */ [data-toc] a[data-active='true'] { - color: #8b5cf6; - border-left-color: #8b5cf6; + color: var(--rd-violet); + border-left-color: var(--rd-violet); } /* Card links in docs */ .fd-card { - background: #111113; - border-color: rgba(255, 255, 255, 0.08); + background: var(--rd-surface); + border-color: var(--rd-border); transition: border-color 0.2s, box-shadow 0.2s; } .fd-card:hover { - border-color: rgba(139, 92, 246, 0.3); - box-shadow: 0 0 30px rgba(139, 92, 246, 0.08); + border-color: var(--rd-violet-glow-30); + box-shadow: 0 0 30px var(--rd-violet-glow-08); } /* Search dialog */ [data-fumadocs-search] { - --color-fd-background: #0c0c0e; - --color-fd-popover: #111113; + --color-fd-background: var(--rd-inset); + --color-fd-popover: var(--rd-surface); } /* Breadcrumbs */ nav[aria-label='Breadcrumb'] { - color: #52525b; + color: var(--rd-faint); } nav[aria-label='Breadcrumb'] a:hover { - color: #8b5cf6; + color: var(--rd-violet); } /* Headings in docs content — slightly brighter */ .fd-page h1, .fd-page h2, .fd-page h3, .fd-page h4 { - color: #fafafa; + color: var(--rd-foreground); } /* Links in docs content */ .fd-page a:not([class]) { - color: #a78bfa; - text-decoration-color: rgba(139, 92, 246, 0.3); + color: var(--rd-violet-light); + text-decoration-color: var(--rd-violet-glow-30); } .fd-page a:not([class]):hover { - color: #c4b5fd; - text-decoration-color: #8b5cf6; + color: var(--rd-violet-lighter); + text-decoration-color: var(--rd-violet); } /* Marketing page utilities */ @@ -272,5 +289,6 @@ nav[aria-label='Breadcrumb'] a:hover { .animate-fade-in-up { animation: none !important; transform: none !important; + transition: none !important; } } diff --git a/apps/web/components/._FaqAccordion.tsx b/apps/web/components/._FaqAccordion.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/._FaqAccordion.tsx and /dev/null differ diff --git a/apps/web/components/._Footer.tsx b/apps/web/components/._Footer.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/._Footer.tsx and /dev/null differ diff --git a/apps/web/components/._NavDropdown.tsx b/apps/web/components/._NavDropdown.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/._NavDropdown.tsx and /dev/null differ diff --git a/apps/web/components/._Navbar.tsx b/apps/web/components/._Navbar.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/._Navbar.tsx and /dev/null differ diff --git a/apps/web/components/FaqAccordion.tsx b/apps/web/components/FaqAccordion.tsx index 61d08d55..252e9a85 100644 --- a/apps/web/components/FaqAccordion.tsx +++ b/apps/web/components/FaqAccordion.tsx @@ -120,30 +120,40 @@ export default function FaqAccordion(props: Props) {
{/* Category tabs */} -
- {categories.map(cat => ( - - ))} +
+ {categories.map(cat => { + const isActive = !isSearching && activeTab === cat.category; + return ( + + ); + })}
{/* Results */} {isSearching && visibleItems.length === 0 ? (

No questions match your search.

) : ( -
+
)} diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index d29d3eac..3c917bd3 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -129,25 +129,11 @@ export default function Footer() {
{/* Bottom bar */} -
+
© {year} Readied. Built with ♥ in Argentina. -
- {socialLinks.map(social => ( - - {social.icon} - - ))} -
diff --git a/apps/web/components/landing/._Audience.tsx b/apps/web/components/landing/._Audience.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/landing/._Audience.tsx and /dev/null differ diff --git a/apps/web/components/landing/._Features.tsx b/apps/web/components/landing/._Features.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/landing/._Features.tsx and /dev/null differ diff --git a/apps/web/components/landing/._Hero.tsx b/apps/web/components/landing/._Hero.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/landing/._Hero.tsx and /dev/null differ diff --git a/apps/web/components/landing/._SocialProof.tsx b/apps/web/components/landing/._SocialProof.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/landing/._SocialProof.tsx and /dev/null differ diff --git a/apps/web/components/landing/._WhyLocal.tsx b/apps/web/components/landing/._WhyLocal.tsx deleted file mode 100644 index 87e41598..00000000 Binary files a/apps/web/components/landing/._WhyLocal.tsx and /dev/null differ diff --git a/apps/web/components/landing/Testimonials.tsx b/apps/web/components/landing/Testimonials.tsx index aa010341..2c3ffd00 100644 --- a/apps/web/components/landing/Testimonials.tsx +++ b/apps/web/components/landing/Testimonials.tsx @@ -56,9 +56,9 @@ const secondRow = reviews.slice(reviews.length / 2); function Stars({ count }: { count: number }) { return ( -
+
{Array.from({ length: count }).map((_, i) => ( - +
); @@ -101,7 +101,7 @@ function ReviewCard({
{text}
-

{date}

+
{date}
); } diff --git a/apps/web/components/landing/WhyLocal.tsx b/apps/web/components/landing/WhyLocal.tsx index bdac47d3..e1123e78 100644 --- a/apps/web/components/landing/WhyLocal.tsx +++ b/apps/web/components/landing/WhyLocal.tsx @@ -194,12 +194,15 @@ function DataFlowDiagram() { export default function WhyLocal() { return ( -
+
{/* Header */}
Why Local -

+

Your notes should live on your machine {' — '}not someone else's server.

diff --git a/apps/web/components/magicui/animated-beam.tsx b/apps/web/components/magicui/animated-beam.tsx index b824e4e9..b892a6ac 100644 --- a/apps/web/components/magicui/animated-beam.tsx +++ b/apps/web/components/magicui/animated-beam.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type RefObject, useEffect, useId, useState } from 'react'; +import { type RefObject, useEffect, useId, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; @@ -32,7 +32,7 @@ export const AnimatedBeam: React.FC = ({ toRef, curvature = 0, reverse = false, - duration = Math.random() * 3 + 4, + duration: durationProp, delay = 0, pathColor = 'gray', pathWidth = 2, @@ -45,6 +45,7 @@ export const AnimatedBeam: React.FC = ({ endYOffset = 0, }) => { const id = useId(); + const duration = useMemo(() => durationProp ?? Math.random() * 3 + 4, [durationProp]); const [pathD, setPathD] = useState(''); const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 }); diff --git a/apps/web/components/magicui/animated-grid-pattern.tsx b/apps/web/components/magicui/animated-grid-pattern.tsx index 777dac64..170c8337 100644 --- a/apps/web/components/magicui/animated-grid-pattern.tsx +++ b/apps/web/components/magicui/animated-grid-pattern.tsx @@ -32,8 +32,16 @@ export function AnimatedGridPattern({ }: AnimatedGridPatternProps) { const id = useId(); const containerRef = useRef(null); + const mountedRef = useRef(true); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + const getPos = useCallback(() => { return [ Math.floor((Math.random() * dimensions.width) / width), @@ -53,19 +61,22 @@ export function AnimatedGridPattern({ const [squares, setSquares] = useState(() => generateSquares(numSquares)); - // Function to update a single square's position - const updateSquarePosition = (id: number) => { - setSquares(currentSquares => - currentSquares.map(sq => - sq.id === id - ? { - ...sq, - pos: getPos(), - } - : sq - ) - ); - }; + const updateSquarePosition = useCallback( + (id: number) => { + if (!mountedRef.current) return; + setSquares(currentSquares => + currentSquares.map(sq => + sq.id === id + ? { + ...sq, + pos: getPos(), + } + : sq + ) + ); + }, + [getPos] + ); // Update squares to animate in useEffect(() => { @@ -78,6 +89,7 @@ export function AnimatedGridPattern({ useEffect(() => { const currentRef = containerRef.current; const resizeObserver = new ResizeObserver(entries => { + if (!mountedRef.current) return; for (const entry of entries) { setDimensions({ width: entry.contentRect.width, diff --git a/apps/web/components/magicui/dot-pattern.tsx b/apps/web/components/magicui/dot-pattern.tsx index bfc57468..ca67a2b4 100644 --- a/apps/web/components/magicui/dot-pattern.tsx +++ b/apps/web/components/magicui/dot-pattern.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useId, useRef, useState } from 'react'; +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; @@ -50,23 +50,27 @@ export function DotPattern({ const safeWidth = Math.max(1, width); const safeHeight = Math.max(1, height); - const dots = Array.from( - { - length: - dimensions.width > 0 && dimensions.height > 0 - ? Math.ceil(dimensions.width / safeWidth) * Math.ceil(dimensions.height / safeHeight) - : 0, - }, - (_, i) => { - const col = i % Math.ceil(dimensions.width / safeWidth); - const row = Math.floor(i / Math.ceil(dimensions.width / safeWidth)); - return { - x: col * safeWidth + cx + x, - y: row * safeHeight + cy + y, - delay: Math.random() * 5, - duration: Math.random() * 3 + 2, - }; - } + const dots = useMemo( + () => + Array.from( + { + length: + dimensions.width > 0 && dimensions.height > 0 + ? Math.ceil(dimensions.width / safeWidth) * Math.ceil(dimensions.height / safeHeight) + : 0, + }, + (_, i) => { + const col = i % Math.ceil(dimensions.width / safeWidth); + const row = Math.floor(i / Math.ceil(dimensions.width / safeWidth)); + return { + x: col * safeWidth + cx + x, + y: row * safeHeight + cy + y, + delay: Math.random() * 5, + duration: Math.random() * 3 + 2, + }; + } + ), + [dimensions.width, dimensions.height, safeWidth, safeHeight, cx, cy, x, y] ); return ( diff --git a/apps/web/components/magicui/hero-video-dialog.tsx b/apps/web/components/magicui/hero-video-dialog.tsx index d73dc0bf..b941e4ed 100644 --- a/apps/web/components/magicui/hero-video-dialog.tsx +++ b/apps/web/components/magicui/hero-video-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Play, XIcon } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; @@ -75,7 +75,23 @@ export function HeroVideoDialog({ className, }: HeroVideoProps) { const [isVideoOpen, setIsVideoOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); const selectedAnimation = animationVariants[animationStyle]; + const closeButtonRef = useRef(null); + + const closeVideo = useCallback(() => { + setIsClosing(true); + setIsVideoOpen(false); + }, []); + + useEffect(() => { + if (!isVideoOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeVideo(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isVideoOpen, closeVideo]); return (
@@ -90,6 +106,7 @@ export function HeroVideoDialog({ alt={thumbnailAlt} width={1920} height={1080} + loading="lazy" className="w-full rounded-xl border border-white/[0.06] shadow-2xl shadow-accent/5 transition-all duration-200 ease-out group-hover:brightness-[0.8]" />
@@ -106,7 +123,7 @@ export function HeroVideoDialog({
- + setIsClosing(false)}> {isVideoOpen && ( { - if (e.key === 'Escape') { - setIsVideoOpen(false); - } - }} - onClick={() => setIsVideoOpen(false)} + onClick={closeVideo} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md" > @@ -129,16 +141,18 @@ export function HeroVideoDialog({ className="relative mx-4 aspect-video w-full max-w-4xl md:mx-0" > setIsVideoOpen(false)} + autoFocus + onClick={closeVideo} className="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 ring-white/10 backdrop-blur-md" >