diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 23dddeeb..b8d178ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,7 +9,7 @@ /packages/tasks/ @tomymaritano /packages/commands/ @tomymaritano /packages/embeds/ @tomymaritano -/packages/design-system/ @tomymaritano +/packages/plugin-api/ @tomymaritano # Desktop app (proprietary) - stricter review /apps/desktop/ @tomymaritano diff --git a/.github/RELEASE.md b/.github/RELEASE.md index e394c47f..9f3f6567 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -2,26 +2,72 @@ ## Overview -Releases are automated via GitHub Actions. When you push a tag starting with `v` (e.g., `v0.1.0`), the release workflow builds and publishes distributables for macOS, Windows, and Linux. +Releases follow Git Flow and are fully automated via GitHub Actions. + +## Flow + +``` +develop → release/X.Y.Z branch → PR to main → merge + ↓ auto-tag.yml triggers: + 1. Creates git tag vX.Y.Z from package.json version + 2. Merges main → develop (keeps branches aligned) + ↓ release.yml triggers (on tag push): + 3. Builds distributables (macOS, Windows, Linux) + 4. Creates draft GitHub Release with artifacts + 5. Posts tweet announcement +``` ## Creating a Release ```bash -# 1. Update version in apps/desktop/package.json -# 2. Commit the change -git add apps/desktop/package.json -git commit -m "chore: bump version to 0.1.0" - -# 3. Create and push the tag -git tag v0.1.0 -git push origin main --tags +# 1. Create release branch from develop +git checkout develop +git pull origin develop +git checkout -b release/0.9.0 + +# 2. Bump version in root package.json + apps/desktop/package.json +# 3. Update CHANGELOG.md +# 4. Commit and push +git add -A +git commit -m "chore(release): bump version to 0.9.0" +git push -u origin release/0.9.0 + +# 5. Create PR targeting main +gh pr create --base main --title "chore(release): v0.9.0" --body "Release 0.9.0" + +# 6. Once CI passes and PR merges → tag + release + sync happen automatically ``` -The workflow will: +The release workflow will: + +1. Validate tag matches `package.json` version +2. Build packages for all platforms (macOS, Windows, Linux) +3. Sign and notarize the macOS build (if secrets are configured) +4. Create a draft GitHub Release with all artifacts +5. Post tweet announcement + +## Versioning + +- **Format:** SemVer with `v` prefix — `v0.9.0`, `v0.9.1`, `v1.0.0` +- **Tags are created ONLY by GitHub Actions** (auto-tag.yml) +- **No manual tags** — the automation reads version from `package.json` + +## Tag Protection Rules (GitHub Settings) + +Configure in **Repository Settings > Rules > Tag protection rules**: + +| Setting | Value | +| ----------------- | --------------------------------- | +| Tag name pattern | `v*` | +| Restrict creation | Enabled | +| Allowed to create | GitHub Actions, Repository admins | +| Force push | Disabled | + +This prevents: -1. Build packages for all platforms -2. Sign and notarize the macOS build (if secrets are configured) -3. Create a draft GitHub release with all artifacts +- Manual tags outside the release flow +- Force-pushing tags (rewriting release history) +- Tags that don't follow SemVer ## Required Secrets diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 00000000..cf55372a --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,73 @@ +name: Deploy API + +on: + push: + branches: [main] + paths: + - 'packages/api/**' + - '.github/workflows/deploy-api.yml' + workflow_dispatch: + inputs: + environment: + description: 'Deploy environment' + required: true + default: 'production' + type: choice + options: + - staging + - production + +jobs: + test: + name: Test API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - run: pnpm install + + - name: Typecheck + run: pnpm --filter @readied/api typecheck + + - name: Test + run: pnpm --filter @readied/api test + + deploy: + name: Deploy API + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - run: pnpm install + + - name: Determine environment + id: env + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "target=${{ inputs.environment }}" >> "$GITHUB_OUTPUT" + else + echo "target=production" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy to Cloudflare Workers + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DEPLOY_ENV: ${{ steps.env.outputs.target }} + run: npx wrangler deploy --env "$DEPLOY_ENV" + working-directory: packages/api diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6ab1438..05e05687 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,32 @@ permissions: contents: write jobs: + # ── Validate tag matches package.json ───────── + validate: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate tag matches package.json + env: + GIT_REF: ${{ github.ref }} + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION=${GIT_REF#refs/tags/v} + + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag version (${TAG_VERSION}) does not match package.json (${PACKAGE_VERSION})" + exit 1 + fi + + echo "✓ Tag v${TAG_VERSION} matches package.json ${PACKAGE_VERSION}" + + # ── Build for all platforms ─────────────────── build: + needs: [validate] + if: always() && (needs.validate.result == 'success' || needs.validate.result == 'skipped') strategy: matrix: include: diff --git a/.gitignore b/.gitignore index cd8f41ae..96f7a24c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ npm-debug.log* # Claude cache (keep plans) .claude/plugins/ .claude/statsig/ +.vercel diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5280569..22432863 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ Readied uses an **Open Core** model: | `packages/tasks` | Task/checkbox parsing | | `packages/commands` | Command palette | | `packages/embeds` | Image/embed handling | -| `packages/design-system` | Design tokens, components | +| `packages/plugin-api` | Plugin API + theme system | ### Proprietary - Not Open for Contributions diff --git a/LICENSE b/LICENSE index 7f0a4d22..8c8c9db7 100644 --- a/LICENSE +++ b/LICENSE @@ -13,7 +13,7 @@ The following packages are licensed under the MIT License: - `packages/tasks/` - Task parsing - `packages/commands/` - Command palette logic - `packages/embeds/` - Embed handling -- `packages/design-system/` - Design tokens and components +- `packages/plugin-api/` - Plugin API and theme system - `packages/product-config/` - Product configuration See the LICENSE file in each package directory for the full MIT License text. diff --git a/README.md b/README.md index cf26b542..7e71efd0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ pnpm dev | `@readied/tasks` | Task/checkbox parsing | | `@readied/commands` | Command palette | | `@readied/embeds` | Image/embed handling | -| `@readied/design-system` | Design tokens, components | +| `@readied/plugin-api` | Plugin API + theme system | ## Contributing diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 00039487..b3e9c070 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -979,13 +979,24 @@ function registerNotebookHandlers(): void { }; }); - // Move notebook + // Move notebook (recursively updates children's depth) ipcMain.handle('notebooks:move', async (_event, id: string, newParentId: string | null) => { const notebook = await repo.get(createNotebookId(id)); if (!notebook) { throw new Error('Notebook not found'); } + // Prevent circular reference: can't move a notebook into its own descendant + if (newParentId) { + let current = await repo.get(createNotebookId(newParentId)); + while (current && current.parentId) { + if (current.parentId === notebook.id) { + throw new Error('CIRCULAR_REFERENCE'); + } + current = await repo.get(current.parentId); + } + } + let newParentDepth = 0; if (newParentId) { const parent = await repo.get(createNotebookId(newParentId)); @@ -1006,6 +1017,19 @@ function registerNotebookHandlers(): void { await repo.save(result.notebook); + // Recursively update children's depth to match the new hierarchy + const updateChildrenDepth = async (parentId: string, parentDepth: number) => { + const children = await repo.getChildren(parentId as ReturnType); + for (const child of children) { + const newChildDepth = parentDepth + 1; + if (child.depth !== newChildDepth) { + await repo.save({ ...child, depth: newChildDepth }); + await updateChildrenDepth(child.id, newChildDepth); + } + } + }; + await updateChildrenDepth(result.notebook.id, result.notebook.depth); + return { id: result.notebook.id, name: result.notebook.name, diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 910a1c26..109b416f 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -186,7 +186,12 @@ export class ApiClient { /** * Generic HTTP request with auth, retry, and error handling */ - private async request(endpoint: string, options: RequestInit = {}, retries = 3): Promise { + private async request( + endpoint: string, + options: RequestInit = {}, + retries = 3, + _isAuthRetry = false + ): Promise { const url = `${this.baseURL}${endpoint}`; // Inject access token if available @@ -212,11 +217,11 @@ export class ApiClient { }); // Handle 401 - Token expired - if (response.status === 401 && tokens) { + if (response.status === 401 && tokens && !_isAuthRetry) { const refreshResult = await this.refreshAccessToken(); switch (refreshResult.type) { case 'success': - return this.request(endpoint, options, 0); + return this.request(endpoint, options, 0, true); case 'network': // Transient failure — throw retryable error so caller can try later throw new ApiError(0, refreshResult.message ?? 'Network error during token refresh'); @@ -233,11 +238,7 @@ export class ApiClient { refreshResult.message ?? 'Session expired. Please sign in again.' ); default: - await this.tokenStorage.clearTokens(); - throw new ApiError( - 401, - refreshResult.message ?? 'Authentication failed. Please sign in again.' - ); + throw new ApiError(0, refreshResult.message ?? 'Transient error during token refresh'); } } diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index c054aa49..fc8a479f 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -16,7 +16,13 @@ import { createTimestamp, type NoteStatus, } from '@readied/core'; -import type { ApiClient, SyncChange, NotebookSyncChange, NotebookPushResult } from './apiClient.js'; +import { + ApiError, + type ApiClient, + type SyncChange, + type NotebookSyncChange, + type NotebookPushResult, +} from './apiClient.js'; import type { EncryptionService } from './encryptionService.js'; // ============================================================================ @@ -85,9 +91,7 @@ function isNetworkError(error: unknown): boolean { * Check if an error represents a 401 Unauthorized response. */ function isAuthError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const msg = error.message.toLowerCase(); - return msg.includes('401') || msg.includes('unauthorized'); + return error instanceof ApiError && error.statusCode === 401; } // ============================================================================ @@ -634,6 +638,31 @@ export class SyncService { }; } catch (error) { const bandwidth = this.apiClient.getBandwidth(); + + // Intentional abort (e.g. logout / stopAutoSync) — not a real error + if (error instanceof Error && error.message === 'Sync aborted') { + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { + notesPulled, + notesPushed, + notebooksPulled, + notebooksPushed, + tagsPulled, + tagsPushed, + conflicts: totalConflicts, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + errorMessage: 'Sync aborted', + }); + + return { + success: false, + changesApplied: 0, + changesPushed: 0, + conflicts: [], + error: 'Sync aborted', + }; + } + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { notesPulled: 0, notesPushed: 0, diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index d1c532f6..4e1cfa61 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -13,6 +13,16 @@ import { } from '@readied/plugin-api'; import type { EditorAPIWithEvents, AppAPIWithEvents, DataAPIWithEvents } from '@readied/plugin-api'; import type { RegisteredCommand } from '@readied/command-registry'; +import type { AiPanelMode } from '@readied/ai-assistant'; +import { + SUMMARIZE_SYSTEM_PROMPT, + SUMMARIZE_USER_TEMPLATE, + REWRITE_SYSTEM_PROMPT, + REWRITE_USER_TEMPLATE, + TWEET_SYSTEM_PROMPT, + TWEET_USER_TEMPLATE, + resolveTemplate, +} from '@readied/ai-assistant'; import { useStore } from 'zustand'; import type { NoteSnapshot, NoteStatus } from '../preload/index'; import { NoteList } from './components/NoteList'; @@ -22,7 +32,7 @@ import { Sidebar } from './components/sidebar'; import { GraphView } from './components/GraphView'; import { CommandPalette } from './components/CommandPalette'; import { AiPanel } from './components/ai/AiPanel'; -import type { AiPanelMode } from '@readied/ai-assistant'; +import type { AiInitialCommand } from './components/ai/AiPanel'; import { LicenseProvider } from './contexts/LicenseContext'; import { ToastProvider, useToast } from './components/Toast'; import type { PluginLoadError } from './stores/pluginRuntimeStore'; @@ -43,6 +53,7 @@ import { useDebouncedSearch } from './hooks/useDebouncedSearch'; import { useCommandKeybindings } from './hooks/useCommandKeybindings'; import { useRegisterAppCommands } from './hooks/useRegisterAppCommands'; import { useRegisterAiCommands } from './hooks/useRegisterAiCommands'; +import { useRegisterPluginAiCommands } from './hooks/useRegisterPluginAiCommands'; import { getEditorView, registry as commandRegistry } from './hooks/useCommandRegistry'; import { builtInPlugins } from './plugins'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; @@ -173,6 +184,7 @@ function NotesApp() { const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isAiPanelOpen, setIsAiPanelOpen] = useState(false); const [aiPanelMode, setAiPanelMode] = useState('chat'); + const [pendingAiCommand, setPendingAiCommand] = useState(null); // Plugin system: create stable EditorAPI and AppAPI (early, so handlers can reference them) const editorAPI = useMemo(() => createEditorAPI(getEditorView), []); @@ -630,11 +642,71 @@ function NotesApp() { setIsAiPanelOpen(true); }, []); + /** Helper: get selection text from editor */ + const getSelectionText = useCallback(() => { + const view = getEditorView(); + if (!view) return ''; + const { from, to } = view.state.selection.main; + return view.state.sliceDoc(from, to); + }, []); + + /** Helper: replace selection in editor */ + const aiReplaceSelection = useCallback((text: string) => { + const view = getEditorView(); + if (!view) return; + const { from, to } = view.state.selection.main; + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + view.focus(); + }, []); + + /** Build and dispatch an AI command with given prompts and output target */ + const dispatchAiCommand = useCallback( + (systemPrompt: string, userTemplate: string, outputTarget: 'replace' | 'insert' | 'panel') => { + const selection = getSelectionText(); + if (!selection) return; // Nothing selected — no-op + const userPrompt = resolveTemplate(userTemplate, { selection }); + setPendingAiCommand({ systemPrompt, userPrompt, outputTarget }); + setAiPanelMode('chat'); + setIsAiPanelOpen(true); + }, + [getSelectionText] + ); + + const handleSummarize = useCallback(() => { + dispatchAiCommand(SUMMARIZE_SYSTEM_PROMPT, SUMMARIZE_USER_TEMPLATE, 'panel'); + }, [dispatchAiCommand]); + + const handleRewrite = useCallback(() => { + dispatchAiCommand(REWRITE_SYSTEM_PROMPT, REWRITE_USER_TEMPLATE, 'replace'); + }, [dispatchAiCommand]); + + const handleTweet = useCallback(() => { + dispatchAiCommand(TWEET_SYSTEM_PROMPT, TWEET_USER_TEMPLATE, 'panel'); + }, [dispatchAiCommand]); + + const clearPendingAiCommand = useCallback(() => { + setPendingAiCommand(null); + }, []); + useRegisterAiCommands({ onTogglePanel: toggleAiPanel, onAskNotes: openAskNotes, + onSummarize: handleSummarize, + onRewrite: handleRewrite, + onTweet: handleTweet, }); + // Bridge: plugin-registered AI commands → command palette → AI panel + const handlePluginAiCommand = useCallback((command: AiInitialCommand) => { + setPendingAiCommand(command); + setAiPanelMode('chat'); + setIsAiPanelOpen(true); + }, []); + useRegisterPluginAiCommands(handlePluginAiCommand); + // AI Panel callbacks — wired to existing app state const aiConfigCache = useRef>({}); @@ -827,6 +899,9 @@ function NotesApp() { getConfig={aiGetConfig} insertAtCursor={aiInsertAtCursor} initialMode={aiPanelMode} + initialCommand={pendingAiCommand} + replaceSelection={aiReplaceSelection} + onCommandExecuted={clearPendingAiCommand} /> )} diff --git a/apps/desktop/src/renderer/components/ai/AiPanel.tsx b/apps/desktop/src/renderer/components/ai/AiPanel.tsx index 9a14c69a..c7dc47b4 100644 --- a/apps/desktop/src/renderer/components/ai/AiPanel.tsx +++ b/apps/desktop/src/renderer/components/ai/AiPanel.tsx @@ -5,6 +5,13 @@ import type { ClaudeMessage, NoteContext, AiPanelMode } from '@readied/ai-assist import { useSettingsStore, selectAi } from '../../stores/settings'; import { AiMessage } from './AiMessage'; +/** Pre-filled command to auto-execute on mount (used by ai:summarize, ai:rewrite, ai:tweet) */ +export interface AiInitialCommand { + systemPrompt: string; + userPrompt: string; + outputTarget: 'replace' | 'insert' | 'panel'; +} + interface AiPanelProps { onClose: () => void; getCurrentNote: () => { id: string; title: string; content: string } | null; @@ -14,6 +21,12 @@ interface AiPanelProps { insertAtCursor: (text: string) => void; /** Initial mode: 'chat' (default) or 'ask-notes' */ initialMode?: AiPanelMode; + /** Pre-filled command to auto-execute (skip input, go straight to Claude) */ + initialCommand?: AiInitialCommand | null; + /** Replace the current editor selection with text */ + replaceSelection?: (text: string) => void; + /** Callback to clear initialCommand after execution */ + onCommandExecuted?: () => void; } export function AiPanel({ @@ -24,6 +37,9 @@ export function AiPanel({ getConfig, insertAtCursor, initialMode = 'chat', + initialCommand = null, + replaceSelection, + onCommandExecuted, }: AiPanelProps) { const aiSettings = useSettingsStore(selectAi); const [messages, setMessages] = useState([]); @@ -50,21 +66,88 @@ export function AiPanel({ setMode(initialMode); }, [initialMode]); + // Auto-execute a pre-filled command (ai:summarize, ai:rewrite, ai:tweet) + useEffect(() => { + if (!initialCommand) return; + + const execute = async () => { + const aiSettings_ = useSettingsStore.getState().settings.ai; + const hasSettingsKey = Boolean(aiSettings_.apiKey); + const apiKey = hasSettingsKey ? aiSettings_.apiKey : getConfig('apiKey'); + if (!apiKey) { + setError('Please set your Anthropic API key in Settings > AI Assistant'); + onCommandExecuted?.(); + return; + } + + const model = hasSettingsKey + ? aiSettings_.model + : getConfig('model') || 'claude-sonnet-4-20250514'; + + // Show user message in chat + const userMsg: ClaudeMessage = { role: 'user', content: initialCommand.userPrompt }; + setMessages(prev => [...prev, userMsg]); + setLoading(true); + setError(null); + + try { + const result = await window.readied.ai.query({ + apiKey, + model, + system: initialCommand.systemPrompt, + messages: [userMsg], + maxTokens: 2048, + }); + + if (result.ok) { + const responseText = result.content; + + if (initialCommand.outputTarget === 'replace' && replaceSelection) { + replaceSelection(responseText); + setMessages(prev => [ + ...prev, + { role: 'assistant', content: responseText + '\n\n*(Selection replaced in editor)*' }, + ]); + } else if (initialCommand.outputTarget === 'insert') { + insertAtCursor(responseText); + setMessages(prev => [ + ...prev, + { role: 'assistant', content: responseText + '\n\n*(Inserted into editor)*' }, + ]); + } else { + // 'panel' — just show in chat + setMessages(prev => [...prev, { role: 'assistant', content: responseText }]); + } + } else { + setError(result.error); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + onCommandExecuted?.(); + } + }; + + execute(); + }, [initialCommand]); // intentionally depends only on initialCommand + const handleSubmit = useCallback(async () => { const query = input.trim(); if (!query || loading) return; // Prefer settings store, fall back to plugin config for backwards compatibility - const apiKey = aiSettings.apiKey || getConfig('apiKey'); + const hasSettingsKey = Boolean(aiSettings.apiKey); + const apiKey = hasSettingsKey ? aiSettings.apiKey : getConfig('apiKey'); if (!apiKey) { setError('Please set your Anthropic API key in Settings > AI Assistant'); return; } - const model = aiSettings.apiKey + const model = hasSettingsKey ? aiSettings.model : getConfig('model') || 'claude-sonnet-4-20250514'; - const maxContextNotes = aiSettings.apiKey + const maxContextNotes = hasSettingsKey ? aiSettings.maxContextNotes : getConfig('maxContextNotes') || 5; diff --git a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.module.css b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.module.css index d4eda940..08dc9be1 100644 --- a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.module.css +++ b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.module.css @@ -82,11 +82,11 @@ } .successIcon { - color: #10b981; + color: var(--success, #10b981); } .errorIcon { - color: #ef4444; + color: var(--danger, #ef4444); } .spinner { @@ -181,6 +181,13 @@ background: var(--bg-hover); } +.errorText { + color: var(--danger, #ef4444); + font-size: 0.875rem; + text-align: center; + margin: 0 0 0.75rem; +} + .actions { display: flex; flex-direction: column; diff --git a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx index 16005d64..267f6ea4 100644 --- a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx +++ b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx @@ -4,7 +4,7 @@ * Multi-step dialog for passwordless authentication via email magic link. */ -import { useState, useCallback, FormEvent } from 'react'; +import { useState, useCallback, useEffect, FormEvent } from 'react'; import { Mail, CheckCircle, AlertCircle, X } from 'lucide-react'; import { useAuthStore } from '../../stores/authStore'; import styles from './MagicLinkFlow.module.css'; @@ -16,13 +16,28 @@ export interface MagicLinkFlowProps { type Step = 'email' | 'sent' | 'verifying' | 'success' | 'error'; -export function MagicLinkFlow({ onSuccess: _onSuccess, onCancel }: MagicLinkFlowProps) { - const { requestMagicLink, error: authError } = useAuthStore(); +export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) { + const { requestMagicLink, isAuthenticated, error: authError } = useAuthStore(); const [step, setStep] = useState('email'); const [email, setEmail] = useState(''); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + // Watch for auth success (deep link verified in background) + useEffect(() => { + if (isAuthenticated && step === 'sent') { + setStep('success'); + onSuccess(); + } + }, [isAuthenticated, step, onSuccess]); + + // Watch for verification errors from the deep link path + useEffect(() => { + if (authError && step === 'sent') { + setError(authError); + } + }, [authError, step]); + const handleSubmitEmail = useCallback( async (e: FormEvent) => { e.preventDefault(); @@ -100,10 +115,12 @@ export function MagicLinkFlow({ onSuccess: _onSuccess, onCancel }: MagicLinkFlow

Check your email

We sent a magic link to {email}. Click the link in the email to - sign in. + sign in. This window will update automatically.

+ {error &&

{error}

} +
- + + + + + + + + + + + + + {/* Plugin sidebar sections */} + + + {shouldShowPrompt && ( +
+ Sync your notes across devices +
+ + +
-
- )} + )} + setIsSyncModalOpen(true)} /> diff --git a/apps/desktop/src/renderer/components/sync/LoginModal.tsx b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx similarity index 53% rename from apps/desktop/src/renderer/components/sync/LoginModal.tsx rename to apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx index c5c40783..431cc1e0 100644 --- a/apps/desktop/src/renderer/components/sync/LoginModal.tsx +++ b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx @@ -1,13 +1,19 @@ /** * Enable Sync Modal * - * Guides the user through enabling cloud sync with magic link auth. - * Shows value proposition → email input → waiting for link → success. + * Guides the user through enabling cloud sync with license-aware step routing. + * Computes smart initial step based on auth + license state: + * - Auth'd + pro/trial → success (already syncing) + * - Auth'd + free/expired → pricing (needs subscription) + * - Not auth'd + trial/pro → email (just needs to sign in) + * - Not auth'd + free/expired → value-prop (full flow) */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Cloud, Mail, CheckCircle, X, RefreshCw } from 'lucide-react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Cloud, Mail, CheckCircle, X, RefreshCw, Sparkles } from 'lucide-react'; import { useAuthStore } from '../../stores/authStore'; +import { useLicense } from '../../contexts/LicenseContext'; +import { getProductConfig } from '@readied/product-config'; import styles from './LoginModal.module.css'; interface EnableSyncModalProps { @@ -15,10 +21,23 @@ interface EnableSyncModalProps { onClose: () => void; } -type Step = 'value-prop' | 'email' | 'checking' | 'sent' | 'success'; +type Step = + | 'value-prop' + | 'pricing' + | 'waiting-payment' + | 'email' + | 'checking' + | 'sent' + | 'success'; const RESEND_COOLDOWN = 60; // seconds +const SYNC_CAPABLE_STATUSES = ['trial', 'pro_active', 'pro_grace']; + +function hasSyncCapability(status: string | undefined): boolean { + return status != null && SYNC_CAPABLE_STATUSES.includes(status); +} + export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { const [email, setEmail] = useState(''); const [step, setStep] = useState('value-prop'); @@ -27,14 +46,51 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { const [isResending, setIsResending] = useState(false); const timerRef = useRef | null>(null); - const { requestMagicLink, isAuthenticated } = useAuthStore(); + const { requestMagicLink, isAuthenticated, error: authError } = useAuthStore(); + const { state: licenseState, openSubscribe } = useLicense(); + const config = useMemo(() => getProductConfig(), []); + const proPricing = config.plans.pro.pricing!; + + const canSync = hasSyncCapability(licenseState?.status); + + // Compute smart initial step based on current auth + license state + // Note: checkout requires auth, so unauthenticated users always go through email first + const computeInitialStep = useCallback((): Step => { + if (isAuthenticated && canSync) return 'success'; + if (isAuthenticated && !canSync) return 'pricing'; + // Not authenticated — always need to sign in first (checkout requires auth) + return 'value-prop'; + }, [isAuthenticated, canSync]); // Watch for auth success (deep link verified in background) + // If user has sync capability → success. If not → they need to pay first. useEffect(() => { if (isAuthenticated && (step === 'sent' || step === 'checking')) { - setStep('success'); + if (canSync) { + setStep('success'); + } else { + setStep('pricing'); + } + } + }, [isAuthenticated, canSync, step]); + + // Watch for verification errors from the deep link path + useEffect(() => { + if (authError && step === 'sent') { + setError(authError); + } + }, [authError, step]); + + // Watch for license state changes while waiting for payment + useEffect(() => { + if (step === 'waiting-payment' && hasSyncCapability(licenseState?.status)) { + if (isAuthenticated) { + setStep('success'); + } else { + setStep('email'); + } } - }, [isAuthenticated, step]); + }, [licenseState?.status, step, isAuthenticated]); // Resend countdown timer useEffect(() => { @@ -54,19 +110,15 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { } }, [resendTimer]); - // Reset state when modal closes + // Reset state when modal opens/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); + if (isOpen) { + setStep(computeInitialStep()); + setEmail(''); + setError(null); + setResendTimer(0); } - }, [isOpen]); + }, [isOpen, computeInitialStep]); const handleSubmitEmail = useCallback( async (e: React.FormEvent) => { @@ -101,10 +153,29 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { } }, [email, resendTimer, isResending, requestMagicLink]); + const handleSelectPlan = useCallback( + async (plan: 'monthly' | 'annual') => { + setError(null); + setStep('waiting-payment'); + const result = await openSubscribe({ plan }); + if (!result.success) { + setError(result.error || 'Failed to open checkout'); + setStep('pricing'); + } + }, + [openSubscribe] + ); + const handleClose = useCallback(() => { onClose(); }, [onClose]); + // Where "Enable Sync" on value-prop should go + // Always go to email first — checkout requires authentication + const handleEnableSync = useCallback(() => { + setStep('email'); + }, []); + if (!isOpen) return null; return ( @@ -126,7 +197,7 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) {
- {/* Step 1: Value Proposition */} + {/* Step: Value Proposition */} {step === 'value-prop' && ( <>
@@ -145,20 +216,90 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) {
  • Works offline, syncs when connected
  • No account required to use Readied locally
  • - + + )} + + {/* Step: Pricing */} + {step === 'pricing' && ( + <> +
    + +
    +

    + Upgrade to Pro +

    +

    + {licenseState?.trial && !licenseState.trial.isExpired + ? config.trialDescription + : 'Get cloud sync and all Pro features'} +

    +
    + + +
    + {error &&

    {error}

    } + )} - {/* Step 2: Email Input */} + {/* Step: Waiting for Payment */} + {step === 'waiting-payment' && ( +
    +
    +

    Complete checkout in your browser...

    +

    This window will update automatically

    + +
    + )} + + {/* Step: Email Input */} {step === 'email' && ( <>
    -

    Enter your email

    -

    We'll send a magic link — no password needed.

    +

    Sign in or create account

    +

    + Enter your email and we'll send you a sign-in link. No password needed — if you're + new, your account is created automatically. +

    {error &&

    {error}

    }
    - ))} +
    + {categories.map(cat => { + const isActive = !isSearching && activeTab === cat.category; + return ( + + ); + })}
    {/* Results */} {isSearching && visibleItems.length === 0 ? (

    No questions match your search.

    ) : ( -
    +
    )} diff --git a/apps/web/components/NavDropdown.tsx b/apps/web/components/NavDropdown.tsx index 8c03e110..00a15eea 100644 --- a/apps/web/components/NavDropdown.tsx +++ b/apps/web/components/NavDropdown.tsx @@ -49,7 +49,7 @@ export default function NavDropdown({ label, items }: NavDropdownProps) { leaveTo="opacity-0 translate-y-1" > -
    +
    {items.map(item => { const className = diff --git a/apps/web/components/NewsletterForm.tsx b/apps/web/components/NewsletterForm.tsx index 83384bf5..74456b28 100644 --- a/apps/web/components/NewsletterForm.tsx +++ b/apps/web/components/NewsletterForm.tsx @@ -87,7 +87,7 @@ export default function NewsletterForm({ compact = false }: NewsletterFormProps) placeholder="your@email.com" required disabled={status === 'loading'} - className="min-w-0 flex-1 rounded-lg border border-white/[0.08] bg-base px-4 py-2.5 text-sm text-white placeholder-[#71717a] transition-colors focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/50 disabled:opacity-50" + className="min-w-0 flex-1 rounded-lg border border-white/8 bg-base px-4 py-2.5 text-sm text-white placeholder-[#71717a] transition-colors focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/50 disabled:opacity-50" />
    - {error && {error}} -
    - ); - } -); - -Input.displayName = 'Input'; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts deleted file mode 100644 index 58d1fc65..00000000 --- a/packages/design-system/src/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Button, type ButtonProps } from './Button'; -export { Input, type InputProps } from './Input'; -export { Card, type CardProps } from './Card'; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts deleted file mode 100644 index bd3df3e7..00000000 --- a/packages/design-system/src/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Components -export * from './components'; - -// Token values as JS constants (for programmatic access) -export const tokens = { - colors: { - bgBase: '#0a0b0d', - bgSurface: '#111214', - bgElevated: '#18191c', - bgInset: '#0d0e10', - textPrimary: '#f4f4f5', - accent: '#5eead4', - accentStrong: '#2dd4bf', - danger: '#f87171', - warning: '#fbbf24', - success: '#34d399', - }, - spacing: { - 0: '0', - 1: '4px', - 2: '8px', - 3: '12px', - 4: '16px', - 5: '20px', - 6: '24px', - 8: '32px', - 10: '40px', - 12: '48px', - 16: '64px', - }, - radii: { - sm: '4px', - md: '6px', - lg: '8px', - xl: '12px', - full: '9999px', - }, - fonts: { - sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", - mono: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace", - }, -} as const; diff --git a/packages/design-system/src/tokens/reset.css b/packages/design-system/src/tokens/reset.css deleted file mode 100644 index aba33a7d..00000000 --- a/packages/design-system/src/tokens/reset.css +++ /dev/null @@ -1,82 +0,0 @@ -/* ============================================= - CSS RESET - Minimal reset for consistent cross-browser styling. - ============================================= */ - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; -} - -body { - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: 1.5; - color: var(--text-primary); - background: var(--bg-base); -} - -a { - color: inherit; - text-decoration: none; -} - -button { - font: inherit; - color: inherit; - background: none; - border: none; - cursor: pointer; -} - -input, -textarea, -select { - font: inherit; - color: inherit; -} - -img, -svg { - display: block; - max-width: 100%; -} - -ul, -ol { - list-style: none; -} - -/* Focus visible for accessibility */ -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} diff --git a/packages/design-system/src/tokens/tokens.css b/packages/design-system/src/tokens/tokens.css deleted file mode 100644 index 9ce8ddf8..00000000 --- a/packages/design-system/src/tokens/tokens.css +++ /dev/null @@ -1,119 +0,0 @@ -/* ============================================= - READIED DESIGN TOKENS - Shared across desktop, marketing, and docs. - ============================================= */ - -:root { - /* ===== SPACING SCALE ===== */ - --space-0: 0; - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; - --space-12: 48px; - --space-16: 64px; - --space-20: 80px; - --space-24: 96px; - - /* ===== COLORS - Background ===== */ - --bg-base: #0a0b0d; - --bg-surface: #111214; - --bg-elevated: #18191c; - --bg-inset: #0d0e10; - - /* ===== COLORS - Border ===== */ - --border: rgba(255, 255, 255, 0.08); - --border-subtle: rgba(255, 255, 255, 0.04); - --border-strong: rgba(255, 255, 255, 0.12); - - /* ===== COLORS - Text ===== */ - --text-primary: #f4f4f5; - --text-secondary: rgba(255, 255, 255, 0.7); - --text-muted: rgba(255, 255, 255, 0.5); - --text-faint: rgba(255, 255, 255, 0.3); - - /* ===== COLORS - Accent (Teal) ===== */ - --accent: #5eead4; - --accent-muted: rgba(94, 234, 212, 0.15); - --accent-strong: #2dd4bf; - --accent-light: #99f6e4; - --accent-glow: rgba(94, 234, 212, 0.3); - - /* ===== COLORS - Semantic ===== */ - --danger: #f87171; - --danger-muted: rgba(248, 113, 113, 0.15); - --warning: #fbbf24; - --warning-muted: rgba(251, 191, 36, 0.15); - --success: #34d399; - --success-muted: rgba(52, 211, 153, 0.15); - - /* ===== TYPOGRAPHY - Fonts ===== */ - --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - 'Helvetica Neue', Arial, sans-serif; - --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; - - /* ===== TYPOGRAPHY - Sizes (Desktop) ===== */ - --text-xs: 11px; - --text-sm: 12px; - --text-base: 13px; - --text-lg: 14px; - --text-xl: 16px; - --text-2xl: 18px; - --text-3xl: 24px; - --text-4xl: 32px; - - /* ===== RADII ===== */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; - --radius-full: 9999px; - - /* ===== TRANSITIONS ===== */ - --duration-fast: 150ms; - --duration-normal: 200ms; - --duration-slow: 300ms; - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); - - /* ===== GLASS MORPHISM ===== */ - --glass-bg: rgba(20, 22, 26, 0.85); - --glass-bg-dark: rgba(10, 11, 13, 0.6); - --glass-bg-accent: rgba(94, 234, 212, 0.08); - --glass-bg-elevated: rgba(30, 32, 36, 0.9); - --glass-border: rgba(255, 255, 255, 0.08); - --glass-border-strong: rgba(255, 255, 255, 0.12); - --glass-border-accent: rgba(94, 234, 212, 0.2); - --blur-sm: blur(8px); - --blur-md: blur(16px); - --blur-lg: blur(24px); - - /* ===== SHADOWS ===== */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); - --shadow-glow: 0 0 24px rgba(94, 234, 212, 0.2); - - /* ===== LAYOUT ===== */ - --nav-height: 60px; - --container-max: 1200px; - --content-max: 720px; -} - -/* ===== MARKETING SITE OVERRIDES ===== */ -@media (min-width: 768px) { - :root { - --text-xs: 12px; - --text-sm: 14px; - --text-base: 16px; - --text-lg: 18px; - --text-xl: 20px; - --text-2xl: 24px; - --text-3xl: 32px; - --text-4xl: 48px; - } -} diff --git a/packages/design-system/tsconfig.json b/packages/design-system/tsconfig.json deleted file mode 100644 index 365cfd0b..00000000 --- a/packages/design-system/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "declaration": true, - "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/product-config/src/facade.ts b/packages/product-config/src/facade.ts index 9a01f92a..6069542c 100644 --- a/packages/product-config/src/facade.ts +++ b/packages/product-config/src/facade.ts @@ -48,7 +48,7 @@ export interface ProductConfig { * @example * ```typescript * const config = getProductConfig(); - * console.log(config.plans.pro.pricing?.intervals.monthly.label); // '$2.99/mo' + * console.log(config.plans.pro.pricing?.intervals.monthly.label); // '€2/mo' * console.log(config.trialDays); // 14 * ``` */ @@ -85,10 +85,10 @@ export function getProductConfig(): ProductConfig { ], pricing: { intervals: { - monthly: { label: '$2.99/mo', amountCents: 299 }, - annual: { label: '$29/year', amountCents: 2900 }, + monthly: { label: '€2/mo', amountCents: 200 }, + annual: { label: '€20/year', amountCents: 2000 }, }, - annualSavings: '19%', + annualSavings: '17%', }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a83d684d..ff274669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,24 +391,6 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@25.4.0)(lightningcss@1.31.1) - packages/design-system: - devDependencies: - '@types/react': - specifier: ^18.2.79 - version: 18.3.27 - '@types/react-dom': - specifier: ^18.2.25 - version: 18.3.7(@types/react@18.3.27) - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - typescript: - specifier: ^5.7.2 - version: 5.9.3 - packages/embeds: dependencies: unist-util-visit: @@ -5186,7 +5168,6 @@ packages: libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lie@3.3.0: