From c12867673a8171e3481d53f4410780e6373afa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Maritano?= Date: Fri, 13 Mar 2026 15:49:50 -0300 Subject: [PATCH 1/6] chore(release): v0.9.0 (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Release 0.9.0 — a major milestone with AI features, sync stability, and a complete website redesign. - **Website redesign** with shadcn/ui + Magic UI - **Auth UX rethink** — Enable Sync flow + middleware fix (500 → 401) - **Sync stability** — error propagation, exponential backoff, abort on logout, typed token refresh - **Sync onboarding** — prompt after 5 notes + offline queue visibility - **AI Commands (Cmd+K v1)** — command panel, settings, keybindings - **AI Knowledge (Cmd+K v2)** — RAG, ask notes, related context - **AI Extensibility** — plugin API, presets import/export - **API documentation** ## Version bumps | File | From | To | |------|------|----| | `package.json` | 0.2.0 | 0.9.0 | | `apps/desktop/package.json` | 0.8.0 | 0.9.0 | ## Checklist - [x] Version bumped in root `package.json` - [x] Version bumped in `apps/desktop/package.json` - [x] CHANGELOG.md updated - [x] Tests pass - [ ] QA smoke test on macOS - [ ] QA smoke test on Windows - [ ] Tag `v0.9.0` after merge ## Post-merge After merging to `main`: 1. Tag `v0.9.0` on main 2. Merge `main` back into `develop` 3. Delete `release/0.9.0` branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * AI Assistant panel with modeful interface (chat/ask-notes modes), configurable via settings * AI command presets support (export/import functionality) * Sync status monitoring with error tracking and pending change count * Enable Sync onboarding flow for new users * Redesigned website with modernized design system * Settings panel for AI configuration (API key, model selection, context limits) * **Bug Fixes** * Improved token handling for deep-link authentication * Better sync error classification and exponential backoff * Enhanced device limit error handling --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 1 + CHANGELOG.md | 45 + ROADMAP.md | 476 ++++++++++ apps/desktop/package.json | 2 +- apps/desktop/src/main/index.ts | 86 +- apps/desktop/src/main/services/apiClient.ts | 97 +- apps/desktop/src/main/services/syncService.ts | 223 ++++- apps/desktop/src/preload/index.ts | 24 + apps/desktop/src/renderer/App.tsx | 95 +- .../src/renderer/components/ai/AiPanel.tsx | 104 ++- .../renderer/components/sidebar/Sidebar.tsx | 25 +- .../components/sidebar/SidebarFooter.tsx | 37 +- .../components/sync/LoginModal.module.css | 99 +- .../renderer/components/sync/LoginModal.tsx | 231 ++++- .../src/renderer/components/sync/index.ts | 2 +- .../renderer/hooks/useRegisterAiCommands.ts | 46 + .../src/renderer/hooks/useSyncOnboarding.ts | 33 + .../renderer/pages/settings/SettingsApp.tsx | 4 + .../settings/components/SettingsSidebar.tsx | 13 +- .../pages/settings/sections/AiSection.tsx | 387 ++++++++ .../src/renderer/plugins/aiAssistant.tsx | 8 +- .../src/renderer/stores/settings/schema.ts | 28 +- .../renderer/stores/settings/settingsStore.ts | 45 +- apps/desktop/src/renderer/stores/syncStore.ts | 89 +- apps/desktop/src/renderer/styles/ai-panel.css | 22 + apps/desktop/src/renderer/styles/global.css | 68 ++ .../auth/verify/AuthVerifyContent.tsx | 118 ++- apps/web/app/(marketing)/changelog/page.tsx | 26 +- apps/web/app/(marketing)/download/page.tsx | 278 +++--- apps/web/app/(marketing)/faq/page.tsx | 6 +- apps/web/app/(marketing)/page.tsx | 21 + apps/web/app/(marketing)/philosophy/page.tsx | 37 +- apps/web/app/(marketing)/plugins/page.tsx | 2 +- apps/web/app/(marketing)/pricing/page.tsx | 152 +-- apps/web/app/(marketing)/privacy/page.tsx | 18 +- apps/web/app/(marketing)/terms/page.tsx | 16 +- apps/web/app/docs/[[...slug]]/page.tsx | 26 +- apps/web/app/docs/layout.tsx | 23 +- apps/web/app/globals.css | 236 ++++- apps/web/app/layout.tsx | 9 +- apps/web/components/FaqAccordion.tsx | 84 +- apps/web/components/Footer.tsx | 283 +++--- apps/web/components/MobileNav.tsx | 208 ----- apps/web/components/Navbar.tsx | 388 +++++--- apps/web/components/landing/Audience.tsx | 126 +-- .../components/landing/ComparisonTable.tsx | 41 - apps/web/components/landing/CreatorStory.tsx | 69 ++ apps/web/components/landing/Features.tsx | 156 +--- apps/web/components/landing/Hero.tsx | 343 +++++-- apps/web/components/landing/SocialProof.tsx | 48 +- apps/web/components/landing/Testimonials.tsx | 134 +++ apps/web/components/landing/VideoGuides.tsx | 180 ++++ apps/web/components/landing/WhyLocal.tsx | 399 ++++++-- apps/web/components/magicui/animated-beam.tsx | 154 +++ .../magicui/animated-grid-pattern.tsx | 141 +++ .../magicui/animated-shiny-text.tsx | 37 + apps/web/components/magicui/border-beam.tsx | 115 +++ apps/web/components/magicui/dot-pattern.tsx | 119 +++ .../components/magicui/hero-video-dialog.tsx | 154 +++ apps/web/components/magicui/marquee.tsx | 76 ++ apps/web/components/magicui/number-ticker.tsx | 79 ++ .../web/components/magicui/shimmer-button.tsx | 76 ++ apps/web/components/magicui/text-reveal.tsx | 83 ++ apps/web/components/ui/accordion.tsx | 56 ++ apps/web/components/ui/badge.tsx | 26 + apps/web/components/ui/button.tsx | 46 + apps/web/components/ui/card.tsx | 55 ++ apps/web/components/ui/separator.tsx | 25 + apps/web/components/ui/sheet.tsx | 91 ++ .../content/docs/architecture/overview.mdx | 164 ++-- apps/web/content/docs/guide/principles.mdx | 52 +- apps/web/content/docs/index.mdx | 53 +- .../content/docs/plugins/getting-started.mdx | 60 +- apps/web/lib/layout.shared.tsx | 26 + apps/web/lib/utils.ts | 6 + apps/web/mdx-components.tsx | 25 +- apps/web/package.json | 31 +- docs/plans/2026-03-12-roadmap-auth-sync-ai.md | 132 +++ .../2026-03-12-website-redesign-design.md | 151 +++ ...6-03-12-website-redesign-implementation.md | 879 ++++++++++++++++++ docs/plans/api-reference.md | 434 +++++++++ package.json | 2 +- packages/ai-assistant/package.json | 5 +- .../ai-assistant/src/aiCommandTypes.test.ts | 216 +++++ packages/ai-assistant/src/aiCommandTypes.ts | 228 +++++ packages/ai-assistant/src/index.ts | 25 +- packages/ai-assistant/src/prompts.ts | 15 + packages/ai-assistant/src/rag.ts | 17 +- packages/api/src/index.ts | 12 + packages/api/src/middleware/auth.ts | 71 +- .../command-registry/src/definitions/ai.ts | 47 + .../src/definitions/editor.ts | 2 +- .../command-registry/src/definitions/index.ts | 1 + packages/command-registry/src/types.ts | 3 +- packages/licensing/package.json | 8 +- packages/plugin-api/src/ai/aiCommandStore.ts | 72 ++ packages/plugin-api/src/index.ts | 5 + .../src/lifecycle/PluginRegistry.ts | 21 + packages/plugin-api/src/types.ts | 32 + packages/product-config/src/index.ts | 18 +- pnpm-lock.yaml | 395 ++++---- vercel.json | 7 + 102 files changed, 8490 insertions(+), 1775 deletions(-) create mode 100644 ROADMAP.md create mode 100644 apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts create mode 100644 apps/desktop/src/renderer/hooks/useSyncOnboarding.ts create mode 100644 apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx delete mode 100644 apps/web/components/MobileNav.tsx delete mode 100644 apps/web/components/landing/ComparisonTable.tsx create mode 100644 apps/web/components/landing/CreatorStory.tsx create mode 100644 apps/web/components/landing/Testimonials.tsx create mode 100644 apps/web/components/landing/VideoGuides.tsx create mode 100644 apps/web/components/magicui/animated-beam.tsx create mode 100644 apps/web/components/magicui/animated-grid-pattern.tsx create mode 100644 apps/web/components/magicui/animated-shiny-text.tsx create mode 100644 apps/web/components/magicui/border-beam.tsx create mode 100644 apps/web/components/magicui/dot-pattern.tsx create mode 100644 apps/web/components/magicui/hero-video-dialog.tsx create mode 100644 apps/web/components/magicui/marquee.tsx create mode 100644 apps/web/components/magicui/number-ticker.tsx create mode 100644 apps/web/components/magicui/shimmer-button.tsx create mode 100644 apps/web/components/magicui/text-reveal.tsx create mode 100644 apps/web/components/ui/accordion.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/lib/layout.shared.tsx create mode 100644 apps/web/lib/utils.ts create mode 100644 docs/plans/2026-03-12-roadmap-auth-sync-ai.md create mode 100644 docs/plans/2026-03-12-website-redesign-design.md create mode 100644 docs/plans/2026-03-12-website-redesign-implementation.md create mode 100644 docs/plans/api-reference.md create mode 100644 packages/ai-assistant/src/aiCommandTypes.test.ts create mode 100644 packages/ai-assistant/src/aiCommandTypes.ts create mode 100644 packages/command-registry/src/definitions/ai.ts create mode 100644 packages/plugin-api/src/ai/aiCommandStore.ts create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index a16e43db..cd8f41ae 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ release/ # OS .DS_Store Thumbs.db +._* # Logs *.log 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/index.ts b/apps/desktop/src/main/index.ts index 4ead86a6..00039487 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -90,6 +90,9 @@ let deviceInfo: DeviceInfo | null = null; let apiClient: ApiClient | null = null; let encryptionService: EncryptionService | null = null; let syncService: SyncService | null = null; + +// Pending deep link token — stored if the deep link arrives before the window is ready +let pendingAuthToken: string | null = null; // Git service (initialized on app ready) let gitService: GitService | null = null; @@ -296,6 +299,17 @@ function createWindow(): void { mainWindow.show(); }); + // Deliver any pending deep link auth token once the renderer is ready + mainWindow.webContents.on('did-finish-load', () => { + if (pendingAuthToken) { + getLogger().info('Delivering queued auth token to renderer'); + mainWindow.webContents.send('auth:verify-token', pendingAuthToken); + mainWindow.show(); + mainWindow.focus(); + pendingAuthToken = null; + } + }); + // Load renderer if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); @@ -1384,6 +1398,15 @@ function registerAuthSyncHandlers(): void { const storage = tokenStorage; const sync = syncService; + // Broadcast sync status events to all renderer windows + sync.onStatusChange(event => { + BrowserWindow.getAllWindows().forEach(win => { + if (!win.isDestroyed()) { + win.webContents.send('sync:status-changed', event); + } + }); + }); + // ═══════════════════════════════════════════════════════════════════════════ // Authentication // ═══════════════════════════════════════════════════════════════════════════ @@ -1435,6 +1458,8 @@ function registerAuthSyncHandlers(): void { // Logout and clear tokens ipcMain.handle('auth:logout', async () => { try { + // Abort any in-flight sync operations before clearing tokens + sync?.stopAutoSync(); await storage.clearTokens(); return { success: true }; } catch (error) { @@ -1532,6 +1557,8 @@ function registerAuthSyncHandlers(): void { cursor: state.cursor, lastSyncAt: state.lastSyncAt, isSyncing: state.isSyncing, + lastError: state.lastError, + consecutiveFailures: state.consecutiveFailures, }; } catch (error) { return { @@ -1541,6 +1568,15 @@ function registerAuthSyncHandlers(): void { } }); + // Get pending change count (offline queue size) + ipcMain.handle('sync:pendingCount', async () => { + try { + return { success: true, count: sync.getPendingCount() }; + } catch (error) { + return { success: false, count: 0, error: error instanceof Error ? error.message : 'Failed' }; + } + }); + // Resolve conflict ipcMain.handle( 'sync:resolveConflict', @@ -2173,6 +2209,47 @@ function registerAiHandlers(): void { } } ); + + // Export AI command preset — opens a save dialog and writes the JSON file + ipcMain.handle('ai:exportPreset', async (_event, presetJson: string) => { + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Export AI Command Preset', + defaultPath: 'ai-commands.json', + filters: [{ name: 'JSON Files', extensions: ['json'] }], + }); + + if (canceled || !filePath) { + return { ok: false, error: 'Export cancelled' }; + } + + try { + await writeFile(filePath, presetJson, 'utf-8'); + return { ok: true, filePath }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }); + + // Import AI command preset — opens a file dialog and reads the JSON file + ipcMain.handle('ai:importPreset', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: 'Import AI Command Preset', + filters: [{ name: 'JSON Files', extensions: ['json'] }], + properties: ['openFile'], + }); + + if (canceled || filePaths.length === 0) { + return { ok: false, error: 'Import cancelled' }; + } + + try { + const content = await readFile(filePaths[0]!, 'utf-8'); + // Return the raw JSON string — validation happens in the renderer + return { ok: true, content }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }); } /** Initialize auto-updater */ @@ -2467,12 +2544,17 @@ app.on('open-url', (event, url) => { if (token) { log.info('Auth verification token received via deep link'); - // Send token to renderer process - const mainWin = BrowserWindow.getAllWindows().find(win => !win.isDestroyed()); + // Send token to renderer process — queue if window isn't ready yet + const mainWin = BrowserWindow.getAllWindows().find( + win => !win.isDestroyed() && win.webContents.isLoading() === false + ); if (mainWin) { mainWin.webContents.send('auth:verify-token', token); mainWin.show(); mainWin.focus(); + } else { + log.info('Window not ready, queuing auth token for later delivery'); + pendingAuthToken = token; } } else { log.warn('Deep link missing token parameter'); diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 9b1721ff..910a1c26 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -140,6 +140,13 @@ export class ApiError extends Error { } } +export type RefreshErrorType = 'success' | 'expired' | 'network' | 'device_limit' | 'unknown'; + +export interface RefreshResult { + type: RefreshErrorType; + message?: string; +} + // ============================================================================ // ApiClient Class // ============================================================================ @@ -149,7 +156,7 @@ export class ApiClient { private tokenStorage: TokenStorage; private deviceInfo: DeviceInfo; private isRefreshing = false; - private refreshPromise: Promise | null = null; + private refreshPromise: Promise | null = null; private _bytesSent = 0; private _bytesReceived = 0; @@ -206,14 +213,31 @@ export class ApiClient { // Handle 401 - Token expired if (response.status === 401 && tokens) { - const refreshed = await this.refreshAccessToken(); - if (refreshed) { - // Retry request with new token - return this.request(endpoint, options, 0); - } else { - // Refresh failed - clear tokens - await this.tokenStorage.clearTokens(); - throw new ApiError(401, 'Session expired. Please sign in again.'); + const refreshResult = await this.refreshAccessToken(); + switch (refreshResult.type) { + case 'success': + return this.request(endpoint, options, 0); + case 'network': + // Transient failure — throw retryable error so caller can try later + throw new ApiError(0, refreshResult.message ?? 'Network error during token refresh'); + case 'device_limit': + throw new ApiError( + 403, + refreshResult.message ?? + 'Device limit exceeded. Please remove a device and try again.' + ); + case 'expired': + await this.tokenStorage.clearTokens(); + throw new ApiError( + 401, + 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.' + ); } } @@ -251,7 +275,7 @@ export class ApiClient { /** * Refreshes the access token using the refresh token */ - async refreshAccessToken(): Promise { + async refreshAccessToken(): Promise { // Prevent concurrent refresh requests if (this.isRefreshing && this.refreshPromise) { return this.refreshPromise; @@ -269,11 +293,11 @@ export class ApiClient { } } - private async _refreshAccessToken(): Promise { + private async _refreshAccessToken(): Promise { try { const refreshToken = await this.tokenStorage.getRefreshToken(); if (!refreshToken) { - return false; + return { type: 'expired', message: 'No refresh token available' }; } const response = await fetch(`${this.baseURL}/auth/refresh`, { @@ -286,14 +310,55 @@ export class ApiClient { }); if (!response.ok) { - return false; + const errorBody = await response.json().catch(() => ({})); + const serverMessage = + (errorBody as Record).error ?? + (errorBody as Record).message ?? + 'Token refresh failed'; + + if (response.status === 401 || response.status === 403) { + // Check for device limit (e.g. 403 with specific error code) + if ( + response.status === 403 && + ((errorBody as Record).code === 'DEVICE_LIMIT' || + serverMessage.toLowerCase().includes('device limit')) + ) { + console.error('[ApiClient] Token refresh failed: device limit exceeded', { + status: response.status, + serverMessage, + }); + return { type: 'device_limit', message: serverMessage }; + } + // Refresh token expired or revoked — user must re-login + console.error('[ApiClient] Token refresh failed: token expired/revoked', { + status: response.status, + serverMessage, + }); + return { type: 'expired', message: serverMessage }; + } + + if (response.status >= 500) { + console.error('[ApiClient] Token refresh failed: server error', { + status: response.status, + serverMessage, + }); + return { type: 'network', message: serverMessage }; + } + + console.error('[ApiClient] Token refresh failed: unexpected status', { + status: response.status, + serverMessage, + }); + return { type: 'unknown', message: serverMessage }; } const data = (await response.json()) as AuthResponse; await this.tokenStorage.saveTokens(data.accessToken, data.refreshToken); - return true; - } catch { - return false; + return { type: 'success' }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown network error'; + console.error('[ApiClient] Token refresh failed: network error', { message }); + return { type: 'network', message }; } } diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index 7cd9e7c7..c054aa49 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -46,6 +46,48 @@ interface SyncState { notebookCursor: number; lastSyncAt: number | null; isSyncing: boolean; + lastError: string | null; + consecutiveFailures: number; +} + +export type SyncStatusEvent = + | { type: 'sync-start' } + | { type: 'sync-success'; changesApplied: number; changesPushed: number } + | { type: 'sync-error'; error: string; isNetworkError: boolean; consecutiveFailures: number } + | { type: 'auth-expired' }; + +export type SyncStatusListener = (event: SyncStatusEvent) => void; + +// ============================================================================ +// Helpers +// ============================================================================ + +const MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Classify an error message as a network error (transient) vs an actual failure. + */ +function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + return ( + msg.includes('fetch') || + msg.includes('network') || + msg.includes('enotfound') || + msg.includes('econnrefused') || + msg.includes('econnreset') || + msg.includes('timeout') || + msg.includes('abort') + ); +} + +/** + * 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'); } // ============================================================================ @@ -60,6 +102,9 @@ export class SyncService { private state: SyncState; private autoSyncTimer: NodeJS.Timeout | null = null; private autoSyncInterval: number = 5 * 60 * 1000; // 5 minutes + private baseAutoSyncInterval: number = 5 * 60 * 1000; // remember the original interval for reset + private abortController: AbortController | null = null; + private statusListener: SyncStatusListener | null = null; constructor( apiClient: ApiClient, @@ -78,6 +123,8 @@ export class SyncService { notebookCursor: 0, lastSyncAt: null, isSyncing: false, + lastError: null, + consecutiveFailures: 0, }; } @@ -85,6 +132,27 @@ export class SyncService { // Public API // ========================================================================== + /** + * Register a listener that receives sync status events. + * Returns an unsubscribe function. + */ + onStatusChange(listener: SyncStatusListener): () => void { + this.statusListener = listener; + return () => { + if (this.statusListener === listener) { + this.statusListener = null; + } + }; + } + + private emitStatus(event: SyncStatusEvent): void { + try { + this.statusListener?.(event); + } catch { + // Don't let listener errors break sync + } + } + /** * Pull changes from server and apply to local database */ @@ -386,6 +454,15 @@ export class SyncService { } } + /** + * Throw if the current sync operation has been aborted. + */ + private checkAborted(): void { + if (this.abortController?.signal.aborted) { + throw new Error('Sync aborted'); + } + } + /** * Perform full sync cycle (pull + push) */ @@ -401,6 +478,8 @@ export class SyncService { } this.state.isSyncing = true; + this.abortController = new AbortController(); + this.emitStatus({ type: 'sync-start' }); const historyId = randomUUID(); this.noteRepository.createSyncHistoryEntry(historyId); @@ -416,6 +495,7 @@ export class SyncService { try { // Step 1: Pull notebooks first (notes depend on notebooks) + this.checkAborted(); const nbPullResult = await this.pullNotebooks(); if (!nbPullResult.success) { console.error('Failed to pull notebooks:', nbPullResult.error); @@ -423,6 +503,7 @@ export class SyncService { notebooksPulled = nbPullResult.changes?.length ?? 0; // Step 2: Push pending notebook changes + this.checkAborted(); const nbPushResult = await this.pushNotebooks(); if (!nbPushResult.success) { console.error('Failed to push notebooks:', nbPushResult.error); @@ -430,6 +511,7 @@ export class SyncService { notebooksPushed = nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0; // Step 3: Pull note changes from server + this.checkAborted(); const pullResult = await this.pull(); if (!pullResult.success) { @@ -458,6 +540,7 @@ export class SyncService { totalConflicts = pullResult.conflicts.length; // Step 4: Push local note changes + this.checkAborted(); let changesPushed = 0; const pendingChanges = this.noteRepository.getPendingChanges(50); @@ -493,6 +576,7 @@ export class SyncService { notesPushed = changesPushed; // Step 5: Pull tags + this.checkAborted(); const tagPull = await this.pullTags(); if (!tagPull.success) { console.error('Tag pull failed:', tagPull.error); @@ -500,6 +584,7 @@ export class SyncService { tagsPulled = tagPull.applied ?? 0; // Step 6: Push tags + this.checkAborted(); const tagPush = await this.pushTags(); if (!tagPush.success) { console.error('Tag push failed:', tagPush.error); @@ -526,11 +611,25 @@ export class SyncService { } ); + const totalApplied = pullResult.changes.length + (nbPullResult.changes?.length ?? 0); + const totalPushed = + changesPushed + (nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0); + + // Success — reset error tracking and backoff + this.state.lastError = null; + this.state.consecutiveFailures = 0; + this.resetAutoSyncInterval(); + + this.emitStatus({ + type: 'sync-success', + changesApplied: totalApplied, + changesPushed: totalPushed, + }); + return { success: true, - changesApplied: pullResult.changes.length + (nbPullResult.changes?.length ?? 0), - changesPushed: - changesPushed + (nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0), + changesApplied: totalApplied, + changesPushed: totalPushed, conflicts: pullResult.conflicts, }; } catch (error) { @@ -547,15 +646,51 @@ export class SyncService { bytesReceived: bandwidth.bytesReceived, errorMessage: error instanceof Error ? error.message : 'Sync failed', }); + + const errorMsg = error instanceof Error ? error.message : 'Sync failed'; + + // Handle 401 — stop auto-sync entirely + if (isAuthError(error)) { + this.state.lastError = errorMsg; + this.state.consecutiveFailures++; + this.stopAutoSync(); + this.emitStatus({ type: 'auth-expired' }); + + return { + success: false, + changesApplied: 0, + changesPushed: 0, + conflicts: [], + error: errorMsg, + }; + } + + // Track failure and apply backoff + this.state.consecutiveFailures++; + this.state.lastError = errorMsg; + const isTransient = isNetworkError(error); + + if (this.autoSyncTimer) { + this.applyBackoff(); + } + + this.emitStatus({ + type: 'sync-error', + error: errorMsg, + isNetworkError: isTransient, + consecutiveFailures: this.state.consecutiveFailures, + }); + return { success: false, changesApplied: 0, changesPushed: 0, conflicts: [], - error: error instanceof Error ? error.message : 'Sync failed', + error: errorMsg, }; } finally { this.state.isSyncing = false; + this.abortController = null; } } @@ -586,30 +721,36 @@ export class SyncService { startAutoSync(intervalMs?: number): void { if (intervalMs) { this.autoSyncInterval = intervalMs; + this.baseAutoSyncInterval = intervalMs; } // Clear existing timer this.stopAutoSync(); - // Start new timer - this.autoSyncTimer = setInterval(() => { - this.syncNow().catch(error => { - console.error('Auto-sync failed:', error); - }); - }, this.autoSyncInterval); + // Reset failure tracking when explicitly starting + this.state.consecutiveFailures = 0; + this.state.lastError = null; + + this.scheduleNextSync(); console.warn(`Auto-sync started (interval: ${this.autoSyncInterval}ms)`); } /** - * Stop auto-sync timer + * Stop auto-sync timer and abort any in-flight sync operation */ stopAutoSync(): void { if (this.autoSyncTimer) { - clearInterval(this.autoSyncTimer); + clearTimeout(this.autoSyncTimer); this.autoSyncTimer = null; console.warn('Auto-sync stopped'); } + + // Abort any currently-running sync operation + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } } /** @@ -619,6 +760,19 @@ export class SyncService { return { ...this.state }; } + /** + * Get the number of local changes waiting to be pushed to the server. + */ + getPendingCount(): number { + try { + const pendingNotes = this.noteRepository.getPendingChanges(1000); + const pendingNotebooks = this.notebookRepository.getPendingChanges(1000); + return pendingNotes.length + pendingNotebooks.length; + } catch { + return 0; + } + } + /** * Get sync history entries */ @@ -630,6 +784,51 @@ export class SyncService { // Private Methods // ========================================================================== + /** + * Schedule the next auto-sync using setTimeout (allows interval changes between runs). + */ + private scheduleNextSync(): void { + if (this.autoSyncTimer) { + clearTimeout(this.autoSyncTimer); + } + + this.autoSyncTimer = setTimeout(async () => { + try { + await this.syncNow(); + } catch (error) { + console.error('Auto-sync failed:', error); + } + + // Schedule the next run (interval may have changed due to backoff or reset) + if (this.autoSyncTimer !== null) { + this.scheduleNextSync(); + } + }, this.autoSyncInterval); + } + + /** + * Double the auto-sync interval (exponential backoff), capped at MAX_BACKOFF_MS. + */ + private applyBackoff(): void { + const newInterval = Math.min(this.autoSyncInterval * 2, MAX_BACKOFF_MS); + if (newInterval !== this.autoSyncInterval) { + this.autoSyncInterval = newInterval; + console.warn(`Auto-sync backoff: next interval ${this.autoSyncInterval}ms`); + } + // Reschedule with new interval + this.scheduleNextSync(); + } + + /** + * Reset auto-sync interval back to the base value after a successful sync. + */ + private resetAutoSyncInterval(): void { + if (this.autoSyncInterval !== this.baseAutoSyncInterval) { + this.autoSyncInterval = this.baseAutoSyncInterval; + console.warn(`Auto-sync interval reset to ${this.autoSyncInterval}ms`); + } + } + /** * Apply a remote change to local database */ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 29cb0fcc..c52d3863 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -528,8 +528,12 @@ export interface ReadiedAPI { cursor?: number; lastSyncAt?: number | null; isSyncing?: boolean; + lastError?: string | null; + consecutiveFailures?: number; error?: string; }>; + /** Listen for sync status events pushed from main process */ + onStatusChange: (callback: (event: unknown) => void) => () => void; /** Resolve a sync conflict */ resolveConflict: ( noteId: string, @@ -545,6 +549,8 @@ export interface ReadiedAPI { pullTags: () => Promise<{ success: boolean; applied: number; error?: string }>; /** Push tag changes to server */ pushTags: () => Promise<{ success: boolean; pushed: number; error?: string }>; + /** Get number of pending local changes */ + pendingCount: () => Promise<{ success: boolean; count: number; error?: string }>; /** Get sync history */ history: (limit?: number) => Promise<{ success: boolean; @@ -725,6 +731,12 @@ export interface ReadiedAPI { messages: Array<{ role: 'user' | 'assistant'; content: string }>; maxTokens?: number; }) => Promise<{ ok: true; content: string } | { ok: false; error: string }>; + /** Export an AI command preset to a user-chosen file */ + exportPreset: ( + presetJson: string + ) => Promise<{ ok: true; filePath: string } | { ok: false; error: string }>; + /** Import an AI command preset from a user-chosen file */ + importPreset: () => Promise<{ ok: true; content: string } | { ok: false; error: string }>; }; pluginConfig: { /** Get a single config value for a plugin */ @@ -885,7 +897,17 @@ const api: ReadiedAPI = { triggerSync: () => ipcRenderer.invoke('sync:trigger'), pullTags: () => ipcRenderer.invoke('sync:pullTags'), pushTags: () => ipcRenderer.invoke('sync:pushTags'), + pendingCount: () => ipcRenderer.invoke('sync:pendingCount'), history: (limit?: number) => ipcRenderer.invoke('sync:history', limit), + onStatusChange: (callback: (event: unknown) => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: unknown) => { + callback(data); + }; + ipcRenderer.on('sync:status-changed', handler); + return () => { + ipcRenderer.removeListener('sync:status-changed', handler); + }; + }, }, subscription: { getStatus: () => ipcRenderer.invoke('subscription:getStatus'), @@ -999,6 +1021,8 @@ const api: ReadiedAPI = { }, ai: { query: options => ipcRenderer.invoke('ai:query', options), + exportPreset: presetJson => ipcRenderer.invoke('ai:exportPreset', presetJson), + importPreset: () => ipcRenderer.invoke('ai:importPreset'), }, pluginConfig: { get: (pluginId, key) => ipcRenderer.invoke('pluginConfig:get', pluginId, key), diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 4b9a6146..d1c532f6 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -21,6 +21,8 @@ import { NoteWindow } from './components/NoteWindow'; 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 { LicenseProvider } from './contexts/LicenseContext'; import { ToastProvider, useToast } from './components/Toast'; import type { PluginLoadError } from './stores/pluginRuntimeStore'; @@ -40,6 +42,7 @@ import { useSyncLinks } from './hooks/useLinks'; import { useDebouncedSearch } from './hooks/useDebouncedSearch'; import { useCommandKeybindings } from './hooks/useCommandKeybindings'; import { useRegisterAppCommands } from './hooks/useRegisterAppCommands'; +import { useRegisterAiCommands } from './hooks/useRegisterAiCommands'; import { getEditorView, registry as commandRegistry } from './hooks/useCommandRegistry'; import { builtInPlugins } from './plugins'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; @@ -134,6 +137,12 @@ function NotesApp() { return cleanup; }, []); + // Listen for sync status events pushed from main process (auto-sync backoff, auth errors) + useEffect(() => { + const cleanup = useSyncStore.getState().initSyncStatusListener(); + return cleanup; + }, []); + // Handle deep link auth verification (readied://auth/verify?token=xxx) useEffect(() => { const handleAuthVerification = async (...args: unknown[]) => { @@ -162,6 +171,8 @@ function NotesApp() { const { searchQuery, debouncedSearch, handleSearch, clearSearch } = useDebouncedSearch(300); const [isGraphOpen, setIsGraphOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + const [isAiPanelOpen, setIsAiPanelOpen] = useState(false); + const [aiPanelMode, setAiPanelMode] = useState('chat'); // Plugin system: create stable EditorAPI and AppAPI (early, so handlers can reference them) const editorAPI = useMemo(() => createEditorAPI(getEditorView), []); @@ -603,6 +614,69 @@ function NotesApp() { onCommandPalette: toggleCommandPalette, }); + // Register AI commands (toggle panel, ask-notes) + const toggleAiPanel = useCallback(() => { + setIsAiPanelOpen(prev => { + if (!prev) setAiPanelMode('chat'); + return !prev; + }); + }, []); + const closeAiPanel = useCallback(() => { + setIsAiPanelOpen(false); + setAiPanelMode('chat'); + }, []); + const openAskNotes = useCallback(() => { + setAiPanelMode('ask-notes'); + setIsAiPanelOpen(true); + }, []); + + useRegisterAiCommands({ + onTogglePanel: toggleAiPanel, + onAskNotes: openAskNotes, + }); + + // AI Panel callbacks — wired to existing app state + const aiConfigCache = useRef>({}); + + // Load AI plugin config once on mount + useEffect(() => { + window.readied.pluginConfig.getAll('readied-ai-assistant').then(config => { + aiConfigCache.current = config ?? {}; + }); + }, []); + + const aiGetCurrentNote = useCallback(() => { + const note = selectedNoteRef.current; + if (!note) return null; + return { id: note.id, title: note.title, content: note.content }; + }, []); + + const aiSearchNotes = useCallback(async (query: string) => { + const notes = await window.readied.notes.search(query, 20); + return notes.map(n => ({ id: n.id, title: n.title })); + }, []); + + const aiGetNoteById = useCallback(async (id: string) => { + const result = await window.readied.notes.get(id); + if (!result.ok) return null; + return { id: result.data.id, title: result.data.title, content: result.data.content }; + }, []); + + const aiGetConfig = useCallback((key: string): T | undefined => { + return aiConfigCache.current[key] as T | undefined; + }, []); + + const aiInsertAtCursor = useCallback((text: string) => { + const view = getEditorView(); + if (!view) return; + const { from } = view.state.selection.main; + view.dispatch({ + changes: { from, insert: text }, + selection: { anchor: from + text.length }, + }); + view.focus(); + }, []); + // Plugin runtime: init once, React observes const discoveredPlugins = useStore(pluginRuntimeStore, s => s.plugins); const pluginErrors = useStore(pluginRuntimeStore, s => s.errors); @@ -654,9 +728,11 @@ function NotesApp() { // Global keyboard handler (routes through CommandRegistry) useCommandKeybindings({ onEscape: useCallback(() => { - // Cascading escape: command palette → graph → search → deselect note + // Cascading escape: command palette → AI panel → graph → search → deselect note if (isCommandPaletteOpen) { setIsCommandPaletteOpen(false); + } else if (isAiPanelOpen) { + setIsAiPanelOpen(false); } else if (isGraphOpen) { setIsGraphOpen(false); } else if (searchQuery) { @@ -664,7 +740,7 @@ function NotesApp() { } else if (selectedNote) { setSelectedNote(null); } - }, [isCommandPaletteOpen, isGraphOpen, searchQuery, selectedNote, clearSearch]), + }, [isCommandPaletteOpen, isAiPanelOpen, isGraphOpen, searchQuery, selectedNote, clearSearch]), }); return ( @@ -739,6 +815,21 @@ function NotesApp() { /> )} + + {/* AI Assistant Panel — right side */} + {isAiPanelOpen && ( + + )} {/* Plugin Host - manages plugin lifecycle */} diff --git a/apps/desktop/src/renderer/components/ai/AiPanel.tsx b/apps/desktop/src/renderer/components/ai/AiPanel.tsx index 13ae6fe9..9a14c69a 100644 --- a/apps/desktop/src/renderer/components/ai/AiPanel.tsx +++ b/apps/desktop/src/renderer/components/ai/AiPanel.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import { X, Send, Trash2, ArrowDownToLine } from 'lucide-react'; +import { X, Send, Trash2, ArrowDownToLine, BookOpen, MessageSquare } from 'lucide-react'; import { buildRagPrompt } from '@readied/ai-assistant'; -import type { ClaudeMessage, NoteContext } from '@readied/ai-assistant'; +import type { ClaudeMessage, NoteContext, AiPanelMode } from '@readied/ai-assistant'; +import { useSettingsStore, selectAi } from '../../stores/settings'; import { AiMessage } from './AiMessage'; interface AiPanelProps { @@ -11,6 +12,8 @@ interface AiPanelProps { getNoteById: (id: string) => Promise<{ id: string; title: string; content: string } | null>; getConfig: (key: string) => T | undefined; insertAtCursor: (text: string) => void; + /** Initial mode: 'chat' (default) or 'ask-notes' */ + initialMode?: AiPanelMode; } export function AiPanel({ @@ -20,11 +23,15 @@ export function AiPanel({ getNoteById, getConfig, insertAtCursor, + initialMode = 'chat', }: AiPanelProps) { + const aiSettings = useSettingsStore(selectAi); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [mode, setMode] = useState(initialMode); + const [contextCount, setContextCount] = useState(0); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -38,18 +45,28 @@ export function AiPanel({ inputRef.current?.focus(); }, []); + // Sync mode when initialMode prop changes (e.g. ai:ask-notes command while panel open) + useEffect(() => { + setMode(initialMode); + }, [initialMode]); + const handleSubmit = useCallback(async () => { const query = input.trim(); if (!query || loading) return; - const apiKey = getConfig('apiKey'); + // Prefer settings store, fall back to plugin config for backwards compatibility + const apiKey = aiSettings.apiKey || getConfig('apiKey'); if (!apiKey) { - setError('Please set your Anthropic API key in Settings > Plugins > AI Assistant'); + setError('Please set your Anthropic API key in Settings > AI Assistant'); return; } - const model = getConfig('model') || 'claude-sonnet-4-5-20250929'; - const maxContextNotes = getConfig('maxContextNotes') || 5; + const model = aiSettings.apiKey + ? aiSettings.model + : getConfig('model') || 'claude-sonnet-4-20250514'; + const maxContextNotes = aiSettings.apiKey + ? aiSettings.maxContextNotes + : getConfig('maxContextNotes') || 5; setInput(''); setError(null); @@ -63,7 +80,7 @@ export function AiPanel({ // Gather context const currentNote = getCurrentNote(); - // Search for relevant notes + // Search for relevant notes matching the user's query const searchResults = await searchNotes(query); const relevantNotes: NoteContext[] = []; @@ -78,7 +95,31 @@ export function AiPanel({ } } - // Build RAG prompt + // In ask-notes mode or when a current note is selected, also search + // for notes related to the current note's title (if not already found) + if (currentNote && relevantNotes.length < maxContextNotes) { + const relatedResults = await searchNotes(currentNote.title); + const existingIds = new Set([...relevantNotes.map(n => n.id), currentNote.id]); + for (const result of relatedResults) { + if (relevantNotes.length >= maxContextNotes) break; + if (existingIds.has(result.id)) continue; + const note = await getNoteById(result.id); + if (note) { + relevantNotes.push({ + id: note.id, + title: note.title, + content: note.content, + }); + existingIds.add(note.id); + } + } + } + + // Track how many notes are being used as context + const totalContext = relevantNotes.length + (currentNote ? 1 : 0); + setContextCount(totalContext); + + // Build RAG prompt (mode determines the system prompt variant) const { system, messages: ragMessages } = buildRagPrompt({ query, currentNote: currentNote @@ -86,6 +127,7 @@ export function AiPanel({ : null, relevantNotes, history: messages, + mode, }); // Call Claude API via IPC proxy @@ -107,7 +149,17 @@ export function AiPanel({ } finally { setLoading(false); } - }, [input, loading, messages, getConfig, getCurrentNote, searchNotes, getNoteById]); + }, [ + input, + loading, + messages, + aiSettings, + getConfig, + getCurrentNote, + searchNotes, + getNoteById, + mode, + ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -122,6 +174,11 @@ export function AiPanel({ const handleClear = useCallback(() => { setMessages([]); setError(null); + setContextCount(0); + }, []); + + const toggleMode = useCallback(() => { + setMode(prev => (prev === 'chat' ? 'ask-notes' : 'chat')); }, []); const handleInsertLast = useCallback(() => { @@ -136,8 +193,27 @@ export function AiPanel({ return (
- AI Assistant +
+ + {mode === 'ask-notes' ? 'Ask Your Notes' : 'AI Assistant'} + + {contextCount > 0 && ( + + {contextCount} {contextCount === 1 ? 'note' : 'notes'} + + )} +
+ {lastAssistantExists && ( + +
+
+ )} + + setIsSyncModalOpen(true)} /> {/* Modal - rendered at Sidebar level, NOT inside NotebookList */} {isCreateNotebookOpen && ( @@ -157,6 +172,8 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { onCancel={closeCreate} /> )} + + setIsSyncModalOpen(false)} /> ); } diff --git a/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx b/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx index 3588d20c..c5a69817 100644 --- a/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx +++ b/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx @@ -1,11 +1,17 @@ import { memo } from 'react'; -import { LogIn, Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react'; +import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react'; import { useAuthStore } from '../../stores/authStore'; -import { useSyncStore, selectStatus, selectLastSyncAt } from '../../stores/syncStore'; +import { + useSyncStore, + selectStatus, + selectLastSyncAt, + selectConsecutiveFailures, + selectPendingCount, +} from '../../stores/syncStore'; interface SidebarFooterProps { readonly appVersion: string; - readonly onSettingsClick?: () => void; + readonly onEnableSyncClick?: () => void; } function formatRelativeTime(timestamp: number): string { @@ -23,18 +29,21 @@ function formatRelativeTime(timestamp: number): string { export const SidebarFooter = memo(function SidebarFooter({ appVersion, - onSettingsClick, + onEnableSyncClick, }: SidebarFooterProps) { const isAuthenticated = useAuthStore(state => state.isAuthenticated); const email = useAuthStore(state => state.user?.email ?? null); const syncStatus = useSyncStore(selectStatus); const lastSyncAt = useSyncStore(selectLastSyncAt); + const consecutiveFailures = useSyncStore(selectConsecutiveFailures); + const pendingCount = useSyncStore(selectPendingCount); const getSyncIcon = () => { switch (syncStatus) { case 'syncing': return ; case 'error': + case 'auth-expired': return ; case 'offline': return ; @@ -49,6 +58,8 @@ export const SidebarFooter = memo(function SidebarFooter({ return 'Syncing...'; case 'error': return 'Sync failed'; + case 'auth-expired': + return 'Session expired. Please sign in again.'; case 'offline': return 'Offline'; default: @@ -56,6 +67,11 @@ export const SidebarFooter = memo(function SidebarFooter({ } }; + // Show offline queue when offline/error with pending changes, or many consecutive failures + const isOfflineOrError = syncStatus === 'offline' || syncStatus === 'error'; + const showQueueStatus = + isAuthenticated && isOfflineOrError && (pendingCount > 0 || consecutiveFailures >= 2); + return (
{isAuthenticated && email ? ( @@ -71,11 +87,18 @@ export const SidebarFooter = memo(function SidebarFooter({
) : ( - )} + {showQueueStatus && ( + + {pendingCount > 0 + ? `${pendingCount} change${pendingCount === 1 ? '' : 's'} pending` + : 'Offline \u2014 changes will sync when back online'} + + )} v{appVersion} diff --git a/apps/desktop/src/renderer/components/sync/LoginModal.module.css b/apps/desktop/src/renderer/components/sync/LoginModal.module.css index 1fe61740..9853c211 100644 --- a/apps/desktop/src/renderer/components/sync/LoginModal.module.css +++ b/apps/desktop/src/renderer/components/sync/LoginModal.module.css @@ -12,7 +12,7 @@ .modal { position: relative; width: 100%; - max-width: 400px; + max-width: 420px; background: var(--color-bg-primary, white); border-radius: var(--radius-lg, 12px); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); @@ -43,6 +43,22 @@ .content { padding: 32px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.iconWrapper { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-primary-subtle, rgba(59, 130, 246, 0.1)); + border-radius: 50%; + color: var(--color-primary, #3b82f6); + margin-bottom: 16px; } .title { @@ -53,24 +69,43 @@ } .subtitle { - margin: 0 0 24px; + margin: 0 0 20px; font-size: 14px; color: var(--color-text-secondary, #666); + line-height: 1.5; } -.form { - display: flex; - flex-direction: column; - gap: 16px; +/* Benefits list */ +.benefits { + list-style: none; + padding: 0; + margin: 0 0 24px; + width: 100%; + text-align: left; +} + +.benefits li { + position: relative; + padding: 6px 0 6px 24px; + font-size: 14px; + color: var(--color-text-secondary, #555); + line-height: 1.4; } -.label { +.benefits li::before { + content: '\2713'; + position: absolute; + left: 0; + color: var(--color-success, #22c55e); + font-weight: 600; +} + +/* Form */ +.form { display: flex; flex-direction: column; - gap: 6px; - font-size: 14px; - font-weight: 500; - color: var(--color-text-primary, #333); + gap: 12px; + width: 100%; } .input { @@ -93,6 +128,7 @@ color: var(--color-text-tertiary, #999); } +/* Error */ .error { margin: 0; padding: 8px 12px; @@ -100,8 +136,10 @@ color: var(--color-error, #dc2626); background: rgba(220, 38, 38, 0.1); border-radius: var(--radius-md, 6px); + text-align: left; } +/* Primary button */ .button { padding: 12px 16px; font-size: 14px; @@ -112,6 +150,7 @@ border-radius: var(--radius-md, 6px); cursor: pointer; transition: background 0.15s; + width: 100%; } .button:hover { @@ -122,6 +161,7 @@ transform: scale(0.98); } +/* Loading / sent states */ .checking, .sent { display: flex; @@ -129,6 +169,7 @@ align-items: center; text-align: center; padding: 20px 0; + width: 100%; } .spinner { @@ -148,8 +189,11 @@ } .checkIcon { - width: 48px; - height: 48px; + color: var(--color-success, #22c55e); + margin-bottom: 16px; +} + +.successIcon { color: var(--color-success, #22c55e); margin-bottom: 16px; } @@ -163,28 +207,49 @@ .sent p { margin: 0; color: var(--color-text-secondary, #666); + line-height: 1.5; } .hint { - margin-top: 16px !important; + margin-top: 12px !important; font-size: 13px; + color: var(--color-text-tertiary, #999) !important; } -.linkButton { +/* Resend row */ +.resendRow { margin-top: 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.resendTimer { + font-size: 13px; + color: var(--color-text-tertiary, #999); +} + +/* Link button */ +.linkButton { + margin-top: 12px; padding: 0; - font-size: 14px; + font-size: 13px; color: var(--color-primary, #3b82f6); background: none; border: none; cursor: pointer; - text-decoration: underline; + display: inline-flex; + align-items: center; + gap: 4px; } .linkButton:hover { color: var(--color-primary-hover, #2563eb); + text-decoration: underline; } +/* Footer */ .footer { padding: 16px 32px; background: var(--color-bg-secondary, #fafafa); diff --git a/apps/desktop/src/renderer/components/sync/LoginModal.tsx b/apps/desktop/src/renderer/components/sync/LoginModal.tsx index 1a976451..c5c40783 100644 --- a/apps/desktop/src/renderer/components/sync/LoginModal.tsx +++ b/apps/desktop/src/renderer/components/sync/LoginModal.tsx @@ -1,84 +1,191 @@ /** - * Login Modal + * Enable Sync Modal * - * Simple modal for email login with magic link. + * Guides the user through enabling cloud sync with magic link auth. + * Shows value proposition → email input → waiting for link → success. */ -import { useState } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Cloud, Mail, CheckCircle, X, RefreshCw } from 'lucide-react'; +import { useAuthStore } from '../../stores/authStore'; import styles from './LoginModal.module.css'; -interface LoginModalProps { +interface EnableSyncModalProps { isOpen: boolean; onClose: () => void; } -export function LoginModal({ isOpen, onClose }: LoginModalProps) { +type Step = 'value-prop' | 'email' | 'checking' | 'sent' | 'success'; + +const RESEND_COOLDOWN = 60; // seconds + +export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { const [email, setEmail] = useState(''); - const [step, setStep] = useState<'email' | 'checking' | 'sent'>('email'); + const [step, setStep] = useState('value-prop'); const [error, setError] = useState(null); + const [resendTimer, setResendTimer] = useState(0); + const [isResending, setIsResending] = useState(false); + const timerRef = useRef | null>(null); - if (!isOpen) return null; + const { requestMagicLink, isAuthenticated } = useAuthStore(); + + // Watch for auth success (deep link verified in background) + useEffect(() => { + if (isAuthenticated && (step === 'sent' || step === 'checking')) { + setStep('success'); + } + }, [isAuthenticated, step]); + + // Resend countdown timer + useEffect(() => { + if (resendTimer > 0) { + timerRef.current = setInterval(() => { + setResendTimer(prev => { + if (prev <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + } + }, [resendTimer]); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + // Delay reset so close animation can play + const timeout = setTimeout(() => { + setStep('value-prop'); + setEmail(''); + setError(null); + setResendTimer(0); + }, 200); + return () => clearTimeout(timeout); + } + }, [isOpen]); + + const handleSubmitEmail = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setStep('checking'); + + try { + await requestMagicLink(email); + setStep('sent'); + setResendTimer(RESEND_COOLDOWN); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send magic link'); + setStep('email'); + } + }, + [email, requestMagicLink] + ); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleResend = useCallback(async () => { + if (resendTimer > 0 || isResending) return; setError(null); - setStep('checking'); + setIsResending(true); try { - await window.readied.auth.requestMagicLink(email); - setStep('sent'); + await requestMagicLink(email); + setResendTimer(RESEND_COOLDOWN); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to send magic link'); - setStep('email'); + setError(err instanceof Error ? err.message : 'Failed to resend'); + } finally { + setIsResending(false); } - }; + }, [email, resendTimer, isResending, requestMagicLink]); - const handleClose = () => { - setStep('email'); - setEmail(''); - setError(null); + const handleClose = useCallback(() => { onClose(); - }; + }, [onClose]); + + if (!isOpen) return null; return (
-
e.stopPropagation()}> -
-

Sign in to sync

-

Sync your notes across devices with Readied Pro.

+ {/* Step 1: Value Proposition */} + {step === 'value-prop' && ( + <> +
+ +
+

+ Sync across devices +

+

+ Your notes stay on your machine. Enable sync to access them from any device, with + end-to-end encryption. +

+
    +
  • Access notes on all your devices
  • +
  • End-to-end encrypted — only you can read them
  • +
  • Works offline, syncs when connected
  • +
  • No account required to use Readied locally
  • +
+ + + )} + {/* Step 2: Email Input */} {step === 'email' && ( -
- - - {error &&

{error}

} - - +
+ - + )} + {/* Step 3: Sending */} {step === 'checking' && (
@@ -86,21 +193,55 @@ export function LoginModal({ isOpen, onClose }: LoginModalProps) {
)} + {/* Step 4: Email Sent — Waiting for Verification */} {step === 'sent' && (
- - - +

Check your email

We sent a magic link to {email}

-

Click the link in the email to sign in.

- + )} +
+ +
)} + + {/* Step 5: Success */} + {step === 'success' && ( +
+ +

You're syncing!

+

Your notes will now sync across all your devices.

+ +
+ )}
diff --git a/apps/desktop/src/renderer/components/sync/index.ts b/apps/desktop/src/renderer/components/sync/index.ts index 50cda592..008cc558 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 { LoginModal } from './LoginModal'; +export { EnableSyncModal } from './LoginModal'; diff --git a/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts new file mode 100644 index 00000000..5ce462b1 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef } from 'react'; +import { aiCommands } from '@readied/command-registry/definitions'; +import { registry } from './useCommandRegistry'; + +interface AiCommandHandlers { + onTogglePanel: () => void; + onAskNotes: () => void; +} + +/** + * Register AI-related commands (toggle panel, ask-notes, etc.) + * Follows the same pattern as useRegisterAppCommands. + */ +export function useRegisterAiCommands(handlers: AiCommandHandlers): void { + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + const executors: Record void> = { + 'ai:toggle-panel': () => handlersRef.current.onTogglePanel(), + 'ai:ask-notes': () => handlersRef.current.onAskNotes(), + }; + + const unregisters: Array<() => void> = []; + + for (const def of aiCommands) { + const executor = executors[def.id]; + if (executor) { + const unregister = registry.register({ + ...def, + execute: () => { + executor(); + return true; + }, + }); + unregisters.push(unregister); + } + } + + return () => { + for (const unregister of unregisters) { + unregister(); + } + }; + }, []); +} diff --git a/apps/desktop/src/renderer/hooks/useSyncOnboarding.ts b/apps/desktop/src/renderer/hooks/useSyncOnboarding.ts new file mode 100644 index 00000000..88a7e09a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useSyncOnboarding.ts @@ -0,0 +1,33 @@ +/** + * Sync Onboarding Hook + * + * Determines whether to show the "Enable Sync" prompt. + * Shows the prompt when the user has created 5+ notes and hasn't + * dismissed it or already authenticated. + * + * Dismissal is per-session only (React state) — the prompt will + * reappear on the next app launch so the user gets a gentle reminder + * without being nagged within a single session. + */ + +import { useState, useCallback } from 'react'; +import { useAuthStore } from '../stores/authStore'; +import { useNoteCounts } from './useNotes'; + +const NOTE_THRESHOLD = 5; + +export function useSyncOnboarding() { + const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const { data: counts } = useNoteCounts(); + const [dismissed, setDismissed] = useState(false); + + const totalNotes = (counts as { active?: number })?.active ?? 0; + + const shouldShowPrompt = !isAuthenticated && !dismissed && totalNotes >= NOTE_THRESHOLD; + + const dismissPrompt = useCallback(() => { + setDismissed(true); + }, []); + + return { shouldShowPrompt, dismissPrompt, totalNotes }; +} diff --git a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx index b7e1aedc..04ae9487 100644 --- a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx +++ b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx @@ -5,6 +5,7 @@ import { SettingsSidebar } from './components/SettingsSidebar'; import { GeneralSection } from './sections/GeneralSection'; import { EditorSection } from './sections/EditorSection'; import { AppearanceSection } from './sections/AppearanceSection'; +import { AiSection } from './sections/AiSection'; import { AccountSection } from './sections/AccountSection'; import { BackupSection } from './sections/BackupSection'; import { AboutSection } from './sections/AboutSection'; @@ -15,6 +16,7 @@ export type SettingsSection = | 'general' | 'editor' | 'appearance' + | 'ai' | 'plugins' | 'account' | 'backup' @@ -33,6 +35,8 @@ export function SettingsApp() { return ; case 'appearance': return ; + case 'ai': + return ; case 'plugins': return ; case 'account': diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx index 820f0a9b..44371507 100644 --- a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx +++ b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx @@ -1,4 +1,14 @@ -import { Settings, FileText, Palette, User, Database, Info, Download, Puzzle } from 'lucide-react'; +import { + Settings, + FileText, + Palette, + Sparkles, + User, + Database, + Info, + Download, + Puzzle, +} from 'lucide-react'; import type { SettingsSection } from '../SettingsApp'; import styles from './SettingsSidebar.module.css'; @@ -12,6 +22,7 @@ const sections: { id: SettingsSection; label: string; Icon: any }[] = [ { id: 'general', label: 'General', Icon: Settings }, { id: 'editor', label: 'Editor', Icon: FileText }, { id: 'appearance', label: 'Appearance', Icon: Palette }, + { id: 'ai', label: 'AI Assistant', Icon: Sparkles }, { id: 'plugins', label: 'Plugins', Icon: Puzzle }, { id: 'account', label: 'Account', Icon: User }, { id: 'backup', label: 'Backup & Data', Icon: Database }, diff --git a/apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx new file mode 100644 index 00000000..ca5255c9 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx @@ -0,0 +1,387 @@ +/** + * AI Assistant Settings Section + * + * API key configuration, model selection, connection testing, + * and AI command preset import/export. + */ + +import { useState, useCallback, useSyncExternalStore } from 'react'; +import { Eye, EyeOff, Zap, Loader2, CheckCircle, XCircle, Upload, Download } from 'lucide-react'; +import { useSettingsStore, selectAi } from '../../../stores/settings'; +import { SettingGroup } from '../components/SettingGroup'; +import { SettingRow } from '../components/SettingRow'; +import { Select, NumberInput } from '../components/controls'; +import { aiCommandStore } from '@readied/plugin-api'; +import type { AiCommandRegistration } from '@readied/plugin-api'; +import { validateAiCommandPreset, serializePreset } from '@readied/ai-assistant'; +import type { AiCommandPreset } from '@readied/ai-assistant'; +import styles from './Section.module.css'; + +type TestStatus = 'idle' | 'testing' | 'success' | 'error'; + +/** Read the aiCommandStore registrations reactively */ +function useAiCommands(): AiCommandRegistration[] { + return useSyncExternalStore( + cb => aiCommandStore.subscribe(cb), + () => aiCommandStore.getState().registrations + ); +} + +export function AiSection() { + const ai = useSettingsStore(selectAi); + const updateAi = useSettingsStore(s => s.updateAi); + + const [showKey, setShowKey] = useState(false); + const [testStatus, setTestStatus] = useState('idle'); + const [testMessage, setTestMessage] = useState(''); + const [presetMessage, setPresetMessage] = useState<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + + const registeredAiCommands = useAiCommands(); + + const modelOptions = [ + { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, + { value: 'claude-opus-4-20250514', label: 'Claude Opus 4' }, + ]; + + const handleTestConnection = useCallback(async () => { + if (!ai.apiKey) { + setTestStatus('error'); + setTestMessage('Please enter an API key first.'); + return; + } + + setTestStatus('testing'); + setTestMessage(''); + + try { + const result = await window.readied.ai.query({ + apiKey: ai.apiKey, + model: ai.model, + system: 'You are a helpful assistant. Respond with exactly: "Connection successful."', + messages: [{ role: 'user', content: 'Test connection.' }], + maxTokens: 32, + }); + + if (result.ok) { + setTestStatus('success'); + setTestMessage('Connection successful. Your API key is valid.'); + } else { + setTestStatus('error'); + setTestMessage(result.error || 'Unknown error occurred.'); + } + } catch (err) { + setTestStatus('error'); + setTestMessage(err instanceof Error ? err.message : String(err)); + } + }, [ai.apiKey, ai.model]); + + const handleExportPreset = useCallback(async () => { + setPresetMessage(null); + + if (registeredAiCommands.length === 0) { + setPresetMessage({ + type: 'error', + text: 'No custom AI commands to export. Plugins must register commands first.', + }); + return; + } + + const preset: AiCommandPreset = { + name: 'My AI Commands', + version: '1.0.0', + description: 'Exported AI command preset', + commands: registeredAiCommands.map(cmd => ({ + id: cmd.id, + name: cmd.name, + description: cmd.description, + systemPrompt: cmd.systemPrompt, + userPromptTemplate: cmd.userPromptTemplate, + icon: cmd.icon, + outputTarget: cmd.outputTarget, + category: cmd.category, + })), + }; + + try { + const result = await window.readied.ai.exportPreset(serializePreset(preset)); + if (result.ok) { + setPresetMessage({ + type: 'success', + text: `Exported ${preset.commands.length} command(s).`, + }); + } else { + if (result.error !== 'Export cancelled') { + setPresetMessage({ type: 'error', text: result.error }); + } + } + } catch (err) { + setPresetMessage({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + } + }, [registeredAiCommands]); + + const handleImportPreset = useCallback(async () => { + setPresetMessage(null); + + try { + const result = await window.readied.ai.importPreset(); + if (!result.ok) { + if (result.error !== 'Import cancelled') { + setPresetMessage({ type: 'error', text: result.error }); + } + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.content); + } catch { + setPresetMessage({ type: 'error', text: 'Invalid JSON file.' }); + return; + } + + const errors = validateAiCommandPreset(parsed); + if (errors.length > 0) { + setPresetMessage({ type: 'error', text: `Invalid preset: ${errors[0]!.message}` }); + return; + } + + const preset = parsed as AiCommandPreset; + let imported = 0; + + for (const cmd of preset.commands) { + aiCommandStore.getState().register({ + id: `preset:${cmd.id}`, + pluginId: '__preset', + name: cmd.name, + description: cmd.description, + systemPrompt: cmd.systemPrompt, + userPromptTemplate: cmd.userPromptTemplate, + icon: cmd.icon, + outputTarget: cmd.outputTarget, + category: cmd.category, + }); + imported++; + } + + setPresetMessage({ + type: 'success', + text: `Imported ${imported} command(s) from "${preset.name}".`, + }); + } catch (err) { + setPresetMessage({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + } + }, []); + + return ( +
+

AI Assistant

+ + + +
+ updateAi({ apiKey: e.target.value })} + placeholder="sk-ant-..." + autoComplete="off" + spellCheck={false} + style={{ + width: '100%', + maxWidth: 320, + padding: '0.5rem 0.875rem', + background: 'var(--bg-hover)', + border: '1px solid var(--border-strong)', + borderRadius: '0.5rem', + color: 'var(--text-primary)', + fontSize: '0.875rem', + fontFamily: 'inherit', + transition: 'all 0.2s ease', + }} + /> + +
+
+ + + setSearch(e.target.value)} placeholder="Search questions..." - className="w-full rounded-lg border border-white/[0.08] bg-surface/50 py-2.5 pl-10 pr-4 text-sm text-white placeholder-[#71717a] outline-none transition-colors focus:border-accent focus:bg-surface" + className="w-full rounded-lg border border-border bg-surface/50 py-2.5 pl-10 pr-4 text-sm text-white placeholder-text-muted outline-none transition-colors focus:border-accent focus:bg-surface" />
@@ -163,7 +131,7 @@ export default function FaqAccordion(props: Props) { className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ !isSearching && activeTab === cat.category ? 'bg-accent text-white' - : 'border border-white/[0.08] text-[#a1a1aa] hover:bg-white/5 hover:text-white' + : 'border border-border text-text-secondary hover:bg-white/5 hover:text-white' }`} > {cat.category} @@ -173,7 +141,7 @@ export default function FaqAccordion(props: Props) { {/* Results */} {isSearching && visibleItems.length === 0 ? ( -

No questions match your search.

+

No questions match your search.

) : (
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index 6ca42c11..d29d3eac 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -1,170 +1,153 @@ -import Link from 'next/link'; -function GithubIcon({ className }: { className?: string }) { - return ( - - - - ); -} +'use client'; -function TwitterIcon({ className }: { className?: string }) { - return ( - - - - ); -} +import Link from 'next/link'; import NewsletterForm from './NewsletterForm'; +const footerLinks = [ + { + title: 'Product', + links: [ + { label: 'Features', href: '/#features' }, + { label: 'Pricing', href: '/pricing' }, + { label: 'Download', href: '/download' }, + { label: 'Changelog', href: '/changelog' }, + ], + }, + { + title: 'Resources', + links: [ + { label: 'Docs', href: '/docs' }, + { label: 'Philosophy', href: '/philosophy' }, + { label: 'FAQ', href: '/faq' }, + { label: 'Plugins', href: '/plugins' }, + ], + }, + { + title: 'Legal', + links: [ + { label: 'Terms', href: '/terms' }, + { label: 'Privacy', href: '/privacy' }, + ], + }, +]; + +const socialLinks = [ + { + label: 'GitHub', + href: 'https://github.com/tomymaritano/readide', + icon: ( + + ), + }, + { + label: 'X', + href: 'https://x.com/tomymaritano', + icon: ( + + ), + }, + { + label: 'Blog', + href: 'https://medium.com/@tomymaritano', + icon: ( + + ), + }, +]; + export default function Footer() { const year = new Date().getFullYear(); return ( -
-
- {/* Top row: brand */} -
- - - readied. - - -

- The note app that stays out of your way. -

-
- - {/* Navigation columns */} - -
+
- {/* Bottom bar */} -
-
- - © {year} Readied. Built with ♥ for developers. + {/* Bottom bar */} +
+
+ + © {year} Readied. Built with ♥ in Argentina. -
-
- +
+ {socialLinks.map(social => ( + + {social.icon} + + ))} +
diff --git a/apps/web/components/MobileNav.tsx b/apps/web/components/MobileNav.tsx deleted file mode 100644 index d51a354e..00000000 --- a/apps/web/components/MobileNav.tsx +++ /dev/null @@ -1,208 +0,0 @@ -'use client'; - -import { useState, Fragment } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import Link from 'next/link'; - -interface NavLink { - label: string; - href: string; - external?: boolean; -} - -interface NavSection { - title: string; - links: NavLink[]; -} - -interface MobileNavProps { - links: NavLink[]; - sections: NavSection[]; -} - -export default function MobileNav({ links, sections }: MobileNavProps) { - const [isOpen, setIsOpen] = useState(false); - - function close() { - setIsOpen(false); - } - - return ( - <> - {/* Hamburger button */} - - - {/* Full-screen mobile nav dialog */} - - - {/* Backdrop overlay */} - - - - - ); -} diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx index 596404f6..6d0bd4e1 100644 --- a/apps/web/components/Navbar.tsx +++ b/apps/web/components/Navbar.tsx @@ -1,130 +1,302 @@ +'use client'; + import Link from 'next/link'; -import NavDropdown from './NavDropdown'; -import MobileNav from './MobileNav'; +import { useState } from 'react'; +import { Apple } from 'lucide-react'; +import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; + +const docsItems: { label: string; href: string; external?: boolean }[] = [ + { label: 'Documentation', href: '/docs' }, + { label: 'Philosophy', href: '/philosophy' }, + { label: 'FAQ', href: '/faq' }, + { label: 'Plugins', href: '/plugins' }, + { + label: 'Report a Bug', + href: 'https://github.com/tomymaritano/readide/issues/new?template=bug_report.md', + external: true, + }, +]; + +const mobileSections = [ + { + title: 'Product', + links: [ + { label: 'Features', href: '/#features' }, + { label: 'Pricing', href: '/pricing' }, + { label: 'Changelog', href: '/changelog' }, + { label: 'Download', href: '/download' }, + ], + }, + { + title: 'Resources', + links: [ + { label: 'Documentation', href: '/docs' }, + { label: 'Philosophy', href: '/philosophy' }, + { label: 'FAQ', href: '/faq' }, + { label: 'Plugins', href: '/plugins' }, + { + label: 'Report a Bug', + href: 'https://github.com/tomymaritano/readide/issues/new?template=bug_report.md', + external: true, + }, + { + label: 'Blog', + href: 'https://medium.com/@tomymaritano', + external: true, + }, + ], + }, + { + title: 'Community', + links: [ + { + label: 'GitHub', + href: 'https://github.com/tomymaritano/readide', + external: true, + }, + { + label: 'X (Twitter)', + href: 'https://x.com/tomymaritano', + external: true, + }, + ], + }, +]; + +const navLinkClass = + 'px-3 py-1.5 text-[13px] font-medium text-text-secondary transition-colors hover:text-text-primary'; + +const ExternalIcon = () => ( + +); export default function Navbar() { + const [sheetOpen, setSheetOpen] = useState(false); + + function closeSheet() { + setSheetOpen(false); + } + return ( -
-
- {/* Text logo */} - - +
+ {/* Floating pill navbar */} +
+
); } diff --git a/apps/web/components/landing/Audience.tsx b/apps/web/components/landing/Audience.tsx index 6a0091ef..c45ddd3f 100644 --- a/apps/web/components/landing/Audience.tsx +++ b/apps/web/components/landing/Audience.tsx @@ -1,91 +1,51 @@ -import Link from 'next/link'; -import { Download, ArrowRight, Heart, Zap, WifiOff } from 'lucide-react'; -import { getProductConfig } from '@readied/product-config'; +import { Code, PenTool, ShieldCheck } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; -export default function Audience() { - const config = getProductConfig(); +const audiences = [ + { + icon: Code, + title: 'Developers', + description: + 'Keep technical notes, code snippets, and project docs in Markdown — the format you already know.', + }, + { + icon: PenTool, + title: 'Writers', + description: + 'Distraction-free writing that works offline. Your drafts stay local until you decide otherwise.', + }, + { + icon: ShieldCheck, + title: 'Privacy Advocates', + description: + 'No telemetry, no cloud requirement, no tracking. Your notes never leave your machine.', + }, +]; +export default function Audience() { return ( -
-
- {/* CTA */} -
-

- Give it a try. It's free. -

-

- No account needed. No credit card. Download the app, point it at a folder of .md files, - and start writing. That's it. -

+
+
+ Built For +

Made for people who care about their notes

-
- + {audiences.map(audience => ( + - - Download Free - - - View pricing - -
- - {/* Trust signals */} -
-
- - Free forever -
-
- - {config.trialDays}-day Pro trial -
-
- - 100% offline -
-
-
- - {/* Indie developer card */} -
-
- {/* Photo */} -
-
- Tomy Maritano -
-
- - {/* Content */} -
- - Built by an indie developer - -

- Readied is made by{' '} - Tomy Maritano, a developer - who cares about software longevity. No investors. No growth targets. Just a tool - that works. -

- - Read the philosophy - - -
-
+ +
+ +
+

{audience.title}

+

+ {audience.description} +

+
+ + ))}
diff --git a/apps/web/components/landing/ComparisonTable.tsx b/apps/web/components/landing/ComparisonTable.tsx deleted file mode 100644 index 313f3811..00000000 --- a/apps/web/components/landing/ComparisonTable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -export default function ComparisonTable() { - const rows = [ - { bad: 'Server dependency', good: 'Works 100% offline' }, - { bad: 'Vendor lock-in', good: 'Standard .md files' }, - { bad: 'Monthly subscription', good: 'Free forever' }, - { bad: 'Privacy concerns', good: 'Your disk, your data' }, - ]; - - return ( -
- {/* Left: Cloud note apps */} -
-

Cloud note apps

-
    - {rows.map(r => ( -
  • - - {r.bad} -
  • - ))} -
-
- - {/* Right: Readied */} -
-

Readied

-
    - {rows.map(r => ( -
  • - - {r.good} -
  • - ))} -
-
-
- ); -} diff --git a/apps/web/components/landing/CreatorStory.tsx b/apps/web/components/landing/CreatorStory.tsx new file mode 100644 index 00000000..d7c66129 --- /dev/null +++ b/apps/web/components/landing/CreatorStory.tsx @@ -0,0 +1,69 @@ +import Link from 'next/link'; +import { Github, Twitter, Globe } from 'lucide-react'; + +export default function CreatorStory() { + return ( +
+
+ The Story +

+ Who's behind Readied? +

+ + {/* Avatar */} +
+ TM +
+ +

+ Hi, I'm Tomy Maritano — a software + developer from Argentina. +

+ +

+ I built Readied because I was tired of note apps that held my data hostage in proprietary + formats, required an internet connection, or disappeared when the startup behind them shut + down. +

+ +

+ I believe your notes should be{' '} + plain files on your machine, readable by + any editor, forever. Readied is open source, offline-first, and built to last — not to + extract value from your words. +

+ + {/* Social links */} +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/components/landing/Features.tsx b/apps/web/components/landing/Features.tsx index 98dfccdd..7bb4afe2 100644 --- a/apps/web/components/landing/Features.tsx +++ b/apps/web/components/landing/Features.tsx @@ -1,124 +1,58 @@ -import { WifiOff, Zap, Sparkles } from 'lucide-react'; +'use client'; + +import { FileText, Puzzle, WifiOff } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { BorderBeam } from '@/components/magicui/border-beam'; + +const features = [ + { + icon: FileText, + title: 'Markdown Sacred', + description: + 'Your markdown is never auto-modified. What you type is exactly what gets saved. No hidden transformations.', + }, + { + icon: Puzzle, + title: 'Plugin Ecosystem', + description: + '8 built-in plugins with an extensible architecture. Load community plugins to make Readied yours.', + }, + { + icon: WifiOff, + title: 'Offline First', + description: + 'Works 100% offline by default. Optional cloud sync keeps notes across devices when you want it.', + }, +]; export default function Features() { return (
- {/* Section header */}
Features

Tools that get out of your way.

-

- A focused set of tools for people who think in plain text. -

- {/* Row 1: Text left, image right */} -
-
-

- Write in Markdown. See it rendered. -

-

- Split-pane editor with syntax highlighting, live preview, and keyboard shortcuts. No - WYSIWYG weirdness -- just you and your text. -

-

- CodeMirror 6 under the hood. Fast enough for 10,000-line files. -

-
- Writing markdown with live preview -
- - {/* Row 2: Image left, text right (reversed) */} -
- Organizing notes in notebooks -
-

Organize your way.

-

- Notebooks, folders, pinned notes -- structure your thoughts however makes sense to - you. It's your file system, not ours. -

-

- Real .md files on your disk. Open them in VS Code, sync with git, back up however you - want. -

-
-
- - {/* Row 3: Text left, image right */} -
-
-

Find anything, instantly.

-

- Full-text search across all your notes. Backlinks computed on the fly from your files - -- no hidden database required. -

-

- Cmd+P quick-open. Search as you type. Jump between notes in milliseconds. -

-
- Searching across notes with highlighted results -
- - {/* Row 4: Small feature cards (3 columns) */} -
- {/* Offline First */} -
-
- -
-

Works on a plane.

-

- No WiFi? No problem. Readied works entirely offline. Always. -

-
- - {/* Fast & Light */} -
-
- -
-

Opens in under 2 seconds.

-

- No Electron bloat. No loading spinners. Just instant access to your notes. -

-
- - {/* AI Assist (Pro) */} -
-
- - - Pro - -
-

AI that stays local.

-

- Get writing suggestions without sending your notes to the cloud. -

-
+
+ {features.map(feature => ( + + +
+ +
+

{feature.title}

+

{feature.description}

+
+ +
+ ))}
diff --git a/apps/web/components/landing/Hero.tsx b/apps/web/components/landing/Hero.tsx index 78e5589c..c7782380 100644 --- a/apps/web/components/landing/Hero.tsx +++ b/apps/web/components/landing/Hero.tsx @@ -1,81 +1,294 @@ +'use client'; + +import { useState } from 'react'; import Link from 'next/link'; -import { Download, ArrowRight, Shield } from 'lucide-react'; +import { Apple, Github, Play, XIcon } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; import { getProductConfig } from '@readied/product-config'; +import { AnimatedShinyText } from '@/components/magicui/animated-shiny-text'; +import { BorderBeam } from '@/components/magicui/border-beam'; -export default function Hero() { - const config = getProductConfig(); +/* ─── Subtle diagonal light beams ─── */ +function LightBeams() { + return ( +