Sync your notes across devices
-
diff --git a/apps/desktop/src/renderer/components/sync/LoginModal.tsx b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx
similarity index 98%
rename from apps/desktop/src/renderer/components/sync/LoginModal.tsx
rename to apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx
index c5c40783..2999b78b 100644
--- a/apps/desktop/src/renderer/components/sync/LoginModal.tsx
+++ b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx
@@ -59,10 +59,12 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) {
if (!isOpen) {
// Delay reset so close animation can play
const timeout = setTimeout(() => {
- setStep('value-prop');
- setEmail('');
- setError(null);
- setResendTimer(0);
+ if (!isOpen) {
+ setStep('value-prop');
+ setEmail('');
+ setError(null);
+ setResendTimer(0);
+ }
}, 200);
return () => clearTimeout(timeout);
}
diff --git a/apps/desktop/src/renderer/components/sync/index.ts b/apps/desktop/src/renderer/components/sync/index.ts
index 008cc558..ff6cefad 100644
--- a/apps/desktop/src/renderer/components/sync/index.ts
+++ b/apps/desktop/src/renderer/components/sync/index.ts
@@ -6,4 +6,4 @@
export { SyncStatusIndicator } from './SyncStatusIndicator';
export { ConflictResolver } from './ConflictResolver';
-export { EnableSyncModal } from './LoginModal';
+export { EnableSyncModal } from './EnableSyncModal';
diff --git a/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts
index 5ce462b1..14b628f3 100644
--- a/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts
+++ b/apps/desktop/src/renderer/hooks/useRegisterAiCommands.ts
@@ -5,10 +5,13 @@ import { registry } from './useCommandRegistry';
interface AiCommandHandlers {
onTogglePanel: () => void;
onAskNotes: () => void;
+ onSummarize: () => void;
+ onRewrite: () => void;
+ onTweet: () => void;
}
/**
- * Register AI-related commands (toggle panel, ask-notes, etc.)
+ * Register AI-related commands (toggle panel, ask-notes, summarize, rewrite, tweet)
* Follows the same pattern as useRegisterAppCommands.
*/
export function useRegisterAiCommands(handlers: AiCommandHandlers): void {
@@ -19,6 +22,9 @@ export function useRegisterAiCommands(handlers: AiCommandHandlers): void {
const executors: Record
void> = {
'ai:toggle-panel': () => handlersRef.current.onTogglePanel(),
'ai:ask-notes': () => handlersRef.current.onAskNotes(),
+ 'ai:summarize': () => handlersRef.current.onSummarize(),
+ 'ai:rewrite': () => handlersRef.current.onRewrite(),
+ 'ai:tweet': () => handlersRef.current.onTweet(),
};
const unregisters: Array<() => void> = [];
diff --git a/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts
new file mode 100644
index 00000000..b497c9c5
--- /dev/null
+++ b/apps/desktop/src/renderer/hooks/useRegisterPluginAiCommands.ts
@@ -0,0 +1,132 @@
+import { useEffect, useRef } from 'react';
+import { aiCommandStore } from '@readied/plugin-api';
+import type { AiCommandRegistration } from '@readied/plugin-api';
+import { resolveTemplate } from '@readied/ai-assistant';
+import type { AiInitialCommand } from '../components/ai/AiPanel';
+import { registry, getEditorView } from './useCommandRegistry';
+
+/**
+ * Callback invoked when a plugin AI command is executed from the palette.
+ * Receives a fully resolved AiInitialCommand ready for the AI panel.
+ */
+export interface PluginAiCommandExecutor {
+ (command: AiInitialCommand): void;
+}
+
+/**
+ * Bridge between the plugin AI command store and the command palette.
+ *
+ * Subscribes to `aiCommandStore` (Zustand vanilla) and dynamically
+ * registers/unregisters commands in the `CommandRegistry` so they
+ * appear in the command palette.
+ *
+ * When a plugin AI command is executed:
+ * 1. Gets editor selection, note content, and title
+ * 2. Resolves the template using `resolveTemplate()`
+ * 3. Calls the executor callback to open the AI panel with the resolved command
+ */
+export function useRegisterPluginAiCommands(onExecute: PluginAiCommandExecutor): void {
+ const onExecuteRef = useRef(onExecute);
+ onExecuteRef.current = onExecute;
+
+ useEffect(() => {
+ // Track unregister functions keyed by registration id
+ const unregisterMap = new Map void>();
+
+ function registerCommand(reg: AiCommandRegistration): void {
+ // Guard against double-registration
+ if (unregisterMap.has(reg.id)) return;
+
+ const unregister = registry.register({
+ id: `plugin-ai:${reg.id}`,
+ name: `AI: ${reg.name}`,
+ description: reg.description,
+ category: 'ai',
+ context: 'editor',
+ showInPalette: true,
+ icon: reg.icon,
+ execute: () => {
+ // Gather context from editor
+ const view = getEditorView();
+ let selection = '';
+ let note = '';
+ let title = '';
+
+ if (view) {
+ const state = view.state;
+ const sel = state.selection.main;
+ selection = state.sliceDoc(sel.from, sel.to);
+ note = state.doc.toString();
+
+ // Extract title from first heading line
+ const firstLine = state.doc.lineAt(1).text;
+ if (firstLine.startsWith('# ')) {
+ title = firstLine.slice(2).trim();
+ }
+ }
+
+ // Resolve template placeholders
+ const userPrompt = resolveTemplate(reg.userPromptTemplate, {
+ selection,
+ note,
+ title,
+ });
+
+ // Dispatch to AI panel via callback
+ onExecuteRef.current({
+ systemPrompt: reg.systemPrompt,
+ userPrompt,
+ outputTarget: reg.outputTarget ?? 'panel',
+ });
+
+ return true;
+ },
+ });
+
+ unregisterMap.set(reg.id, unregister);
+ }
+
+ function unregisterCommand(id: string): void {
+ const unregister = unregisterMap.get(id);
+ if (unregister) {
+ unregister();
+ unregisterMap.delete(id);
+ }
+ }
+
+ function syncRegistrations(registrations: AiCommandRegistration[]): void {
+ const currentIds = new Set(registrations.map(r => r.id));
+
+ // Remove commands no longer in the store
+ for (const id of unregisterMap.keys()) {
+ if (!currentIds.has(id)) {
+ unregisterCommand(id);
+ }
+ }
+
+ // Add new commands
+ for (const reg of registrations) {
+ if (!unregisterMap.has(reg.id)) {
+ registerCommand(reg);
+ }
+ }
+ }
+
+ // Initial sync with current store state
+ syncRegistrations(aiCommandStore.getState().registrations);
+
+ // Subscribe to future changes
+ const unsubscribe = aiCommandStore.subscribe(state => {
+ syncRegistrations(state.registrations);
+ });
+
+ return () => {
+ unsubscribe();
+ // Clean up all palette registrations
+ for (const unregister of unregisterMap.values()) {
+ unregister();
+ }
+ unregisterMap.clear();
+ };
+ }, []);
+}
diff --git a/apps/desktop/src/renderer/styles/ai-panel.css b/apps/desktop/src/renderer/styles/ai-panel.css
index 4f42528c..4ae24659 100644
--- a/apps/desktop/src/renderer/styles/ai-panel.css
+++ b/apps/desktop/src/renderer/styles/ai-panel.css
@@ -34,7 +34,7 @@
font-size: 10px;
font-weight: 500;
color: var(--accent);
- background: rgba(94, 234, 212, 0.1);
+ background: var(--accent-subtle);
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
@@ -67,7 +67,7 @@
.ai-panel-btn.active {
color: var(--accent);
- background: rgba(94, 234, 212, 0.1);
+ background: var(--accent-subtle);
}
/* Messages area */
diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css
index 049319a5..09b396e3 100644
--- a/apps/desktop/src/renderer/styles/global.css
+++ b/apps/desktop/src/renderer/styles/global.css
@@ -1511,7 +1511,7 @@ input:focus-visible {
justify-content: space-between;
padding: 8px 12px;
margin: 0 8px 4px;
- background: var(--accent-subtle, rgba(59, 130, 246, 0.08));
+ background: var(--accent-subtle, rgba(94, 234, 212, 0.08));
border-radius: var(--radius-sm, 4px);
font-size: var(--text-xs);
color: var(--text-secondary);
@@ -1526,7 +1526,7 @@ input:focus-visible {
margin-left: 8px;
}
-.sidebar-sync-prompt-actions button:first-child {
+.sidebar-sync-prompt-actions .sidebar-sync-prompt-enable {
background: var(--accent);
color: white;
border: none;
@@ -1537,11 +1537,11 @@ input:focus-visible {
font-weight: 500;
}
-.sidebar-sync-prompt-actions button:first-child:hover {
+.sidebar-sync-prompt-actions .sidebar-sync-prompt-enable:hover {
opacity: 0.9;
}
-.sidebar-sync-prompt-actions button:last-child {
+.sidebar-sync-prompt-actions .sidebar-sync-prompt-dismiss {
background: none;
border: none;
color: var(--text-muted);
@@ -1551,7 +1551,7 @@ input:focus-visible {
line-height: 1;
}
-.sidebar-sync-prompt-actions button:last-child:hover {
+.sidebar-sync-prompt-actions .sidebar-sync-prompt-dismiss:hover {
color: var(--text-secondary);
}
diff --git a/apps/desktop/src/renderer/styles/tokens.css b/apps/desktop/src/renderer/styles/tokens.css
index 59830963..49933a05 100644
--- a/apps/desktop/src/renderer/styles/tokens.css
+++ b/apps/desktop/src/renderer/styles/tokens.css
@@ -36,6 +36,7 @@
/* ===== COLORS - Accent ===== */
--accent: #5eead4;
--accent-muted: rgba(94, 234, 212, 0.15);
+ --accent-subtle: rgba(94, 234, 212, 0.1);
--accent-strong: #2dd4bf;
/* ===== COLORS - Semantic ===== */
@@ -138,6 +139,7 @@
/* Accent (lighter versions for light theme) */
--accent: #14b8a6;
+ --accent-subtle: rgba(20, 184, 166, 0.1);
--accent-muted: rgba(20, 184, 166, 0.15);
--accent-strong: #0d9488;
--accent-primary: #14b8a6;
diff --git a/apps/web/._mdx-components.tsx b/apps/web/._mdx-components.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/._mdx-components.tsx and /dev/null differ
diff --git a/apps/web/._package.json b/apps/web/._package.json
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/._package.json and /dev/null differ
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index f9ef312e..4039e06a 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -2,3 +2,4 @@
.source/
node_modules/
out/
+.vercel
diff --git a/apps/web/app/(marketing)/._page.tsx b/apps/web/app/(marketing)/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx b/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/auth/verify/._AuthVerifyContent.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx
index 30b38ee9..2c2fc8d4 100644
--- a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx
+++ b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx
@@ -11,7 +11,7 @@ export default function AuthVerifyContent() {
useEffect(() => {
if (token) {
- window.location.href = `readied://auth/verify?token=${token}`;
+ window.location.href = `readied://auth/verify?token=${encodeURIComponent(token)}`;
const timer = setTimeout(() => {
setShowFallback(true);
@@ -39,8 +39,8 @@ export default function AuthVerifyContent() {
-
This verification link is incomplete or has expired. Please request a new magic link
from the Readied app.
@@ -61,8 +61,8 @@ export default function AuthVerifyContent() {
{!showFallback ? (
<>
The app should open automatically. Hang tight.
+ The app should open automatically. Hang tight.
>
) : (
<>
@@ -79,27 +79,27 @@ export default function AuthVerifyContent() {
The app didn't open automatically. Try clicking the button below.
-
+
Opened this on the wrong device?
-
+
Open this same link on the device where Readied is installed. The magic link is
valid for 15 minutes.
-
+
Don't have Readied yet?{' '}
Download now
diff --git a/apps/web/app/(marketing)/changelog/._page.tsx b/apps/web/app/(marketing)/changelog/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/changelog/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/download/._page.tsx b/apps/web/app/(marketing)/download/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/download/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/faq/._page.tsx b/apps/web/app/(marketing)/faq/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/faq/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/philosophy/._page.tsx b/apps/web/app/(marketing)/philosophy/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/philosophy/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/plugins/._page.tsx b/apps/web/app/(marketing)/plugins/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/plugins/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/pricing/._page.tsx b/apps/web/app/(marketing)/pricing/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/pricing/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx
index f381ca5e..adff55f9 100644
--- a/apps/web/app/(marketing)/pricing/page.tsx
+++ b/apps/web/app/(marketing)/pricing/page.tsx
@@ -14,7 +14,6 @@ import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { BorderBeam } from '@/components/magicui/border-beam';
-import { NumberTicker } from '@/components/magicui/number-ticker';
import {
Accordion,
AccordionItem,
@@ -27,9 +26,8 @@ export default function PricingPage() {
const { plans, guarantees, trialDays, trialDescription } = config;
const proPricing = plans.pro.pricing!;
- // Extract numeric values from price labels for NumberTicker
- const monthlyPrice = proPricing.intervals.monthly.amountCents / 100;
- const annualPrice = proPricing.intervals.annual.amountCents / 100;
+ const monthlyLabel = proPricing.intervals.monthly.label;
+ const annualLabel = proPricing.intervals.annual.label;
const faqs = [
{ q: 'What if you stop developing Readied?', a: guarantees.freeTierForever.description },
@@ -109,27 +107,13 @@ export default function PricingPage() {
-
- $
-
- /mo
-
+
+ {monthlyLabel}
+
or
-
- $
-
-
- /year
-
-
+
+ {annualLabel}
+
Save {proPricing.annualSavings}
diff --git a/apps/web/app/(marketing)/privacy/._page.tsx b/apps/web/app/(marketing)/privacy/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/privacy/._page.tsx and /dev/null differ
diff --git a/apps/web/app/(marketing)/terms/._page.tsx b/apps/web/app/(marketing)/terms/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/(marketing)/terms/._page.tsx and /dev/null differ
diff --git a/apps/web/app/._globals.css b/apps/web/app/._globals.css
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/._globals.css and /dev/null differ
diff --git a/apps/web/app/._layout.tsx b/apps/web/app/._layout.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/._layout.tsx and /dev/null differ
diff --git a/apps/web/app/docs/._layout.tsx b/apps/web/app/docs/._layout.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/docs/._layout.tsx and /dev/null differ
diff --git a/apps/web/app/docs/[[...slug]]/._page.tsx b/apps/web/app/docs/[[...slug]]/._page.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/app/docs/[[...slug]]/._page.tsx and /dev/null differ
diff --git a/apps/web/app/docs/[[...slug]]/page.tsx b/apps/web/app/docs/[[...slug]]/page.tsx
index 64a54b4d..4db6a23e 100644
--- a/apps/web/app/docs/[[...slug]]/page.tsx
+++ b/apps/web/app/docs/[[...slug]]/page.tsx
@@ -1,31 +1,9 @@
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page';
-import defaultMdxComponents from 'fumadocs-ui/mdx';
-import { Card, Cards } from 'fumadocs-ui/components/card';
-import { Callout } from 'fumadocs-ui/components/callout';
-import { Step, Steps } from 'fumadocs-ui/components/steps';
-import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
-import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
-import { File, Folder, Files } from 'fumadocs-ui/components/files';
-import { TypeTable } from 'fumadocs-ui/components/type-table';
+import { useMDXComponents } from '@/mdx-components';
-const mdxComponents = {
- ...defaultMdxComponents,
- Card,
- Cards,
- Callout,
- Step,
- Steps,
- Tab,
- Tabs,
- Accordion,
- Accordions,
- File,
- Folder,
- Files,
- TypeTable,
-};
+const mdxComponents = useMDXComponents({});
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx
index 1bfcc343..ad924e52 100644
--- a/apps/web/app/docs/layout.tsx
+++ b/apps/web/app/docs/layout.tsx
@@ -1,7 +1,7 @@
+import type { ReactNode } from 'react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { source } from '@/lib/source';
import { baseOptions } from '@/lib/layout.shared';
-import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 4f6fafc2..1fb5c9e0 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -27,22 +27,39 @@
/* ─── Fumadocs theme alignment ─── */
/* Override fumadocs CSS variables to match our violet/zinc design system */
:root, .dark {
+ --rd-violet: #8b5cf6;
+ --rd-violet-light: #a78bfa;
+ --rd-violet-lighter: #c4b5fd;
+ --rd-violet-glow-10: rgba(139, 92, 246, 0.1);
+ --rd-violet-glow-12: rgba(139, 92, 246, 0.12);
+ --rd-violet-glow-20: rgba(139, 92, 246, 0.2);
+ --rd-violet-glow-30: rgba(139, 92, 246, 0.3);
+ --rd-violet-glow-08: rgba(139, 92, 246, 0.08);
+ --rd-surface: #111113;
+ --rd-inset: #0c0c0e;
+ --rd-foreground: #fafafa;
+ --rd-muted-foreground: #a1a1aa;
+ --rd-subtle-foreground: #e4e4e7;
+ --rd-faint: #52525b;
+ --rd-border-subtle: rgba(255, 255, 255, 0.06);
+ --rd-border: rgba(255, 255, 255, 0.08);
+
--color-fd-background: #09090b;
- --color-fd-foreground: #fafafa;
- --color-fd-muted: #111113;
- --color-fd-muted-foreground: #a1a1aa;
- --color-fd-popover: #111113;
- --color-fd-popover-foreground: #e4e4e7;
- --color-fd-card: #111113;
- --color-fd-card-foreground: #fafafa;
- --color-fd-border: rgba(255, 255, 255, 0.08);
- --color-fd-primary: #8b5cf6;
+ --color-fd-foreground: var(--rd-foreground);
+ --color-fd-muted: var(--rd-surface);
+ --color-fd-muted-foreground: var(--rd-muted-foreground);
+ --color-fd-popover: var(--rd-surface);
+ --color-fd-popover-foreground: var(--rd-subtle-foreground);
+ --color-fd-card: var(--rd-surface);
+ --color-fd-card-foreground: var(--rd-foreground);
+ --color-fd-border: var(--rd-border);
+ --color-fd-primary: var(--rd-violet);
--color-fd-primary-foreground: #ffffff;
--color-fd-secondary: #1a1a1f;
- --color-fd-secondary-foreground: #e4e4e7;
- --color-fd-accent: rgba(139, 92, 246, 0.12);
- --color-fd-accent-foreground: #e4e4e7;
- --color-fd-ring: #8b5cf6;
+ --color-fd-secondary-foreground: var(--rd-subtle-foreground);
+ --color-fd-accent: var(--rd-violet-glow-12);
+ --color-fd-accent-foreground: var(--rd-subtle-foreground);
+ --color-fd-ring: var(--rd-violet);
}
/* ─── Fumadocs component overrides ─── */
@@ -54,26 +71,26 @@
/* Sidebar links — subtle hover */
.fd-sidebar [data-active='true'] {
- color: #8b5cf6 !important;
+ color: var(--rd-violet) !important;
}
/* Docs nav bar — glass effect */
nav[data-fumadocs] {
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ border-bottom: 1px solid var(--rd-border-subtle);
}
/* Code blocks — darker with violet accents */
pre:has(code) {
- background: #0c0c0e !important;
- border: 1px solid rgba(255, 255, 255, 0.06);
+ background: var(--rd-inset) !important;
+ border: 1px solid var(--rd-border-subtle);
border-radius: 0.75rem;
}
/* Inline code */
:not(pre) > code {
- background: rgba(139, 92, 246, 0.1) !important;
- color: #c4b5fd !important;
- border: 1px solid rgba(139, 92, 246, 0.2);
+ background: var(--rd-violet-glow-10) !important;
+ color: var(--rd-violet-lighter) !important;
+ border: 1px solid var(--rd-violet-glow-20);
border-radius: 0.375rem;
padding: 0.125rem 0.375rem;
font-size: 0.875em;
@@ -81,51 +98,51 @@ pre:has(code) {
/* Table of contents — active item */
[data-toc] a[data-active='true'] {
- color: #8b5cf6;
- border-left-color: #8b5cf6;
+ color: var(--rd-violet);
+ border-left-color: var(--rd-violet);
}
/* Card links in docs */
.fd-card {
- background: #111113;
- border-color: rgba(255, 255, 255, 0.08);
+ background: var(--rd-surface);
+ border-color: var(--rd-border);
transition: border-color 0.2s, box-shadow 0.2s;
}
.fd-card:hover {
- border-color: rgba(139, 92, 246, 0.3);
- box-shadow: 0 0 30px rgba(139, 92, 246, 0.08);
+ border-color: var(--rd-violet-glow-30);
+ box-shadow: 0 0 30px var(--rd-violet-glow-08);
}
/* Search dialog */
[data-fumadocs-search] {
- --color-fd-background: #0c0c0e;
- --color-fd-popover: #111113;
+ --color-fd-background: var(--rd-inset);
+ --color-fd-popover: var(--rd-surface);
}
/* Breadcrumbs */
nav[aria-label='Breadcrumb'] {
- color: #52525b;
+ color: var(--rd-faint);
}
nav[aria-label='Breadcrumb'] a:hover {
- color: #8b5cf6;
+ color: var(--rd-violet);
}
/* Headings in docs content — slightly brighter */
.fd-page h1, .fd-page h2, .fd-page h3, .fd-page h4 {
- color: #fafafa;
+ color: var(--rd-foreground);
}
/* Links in docs content */
.fd-page a:not([class]) {
- color: #a78bfa;
- text-decoration-color: rgba(139, 92, 246, 0.3);
+ color: var(--rd-violet-light);
+ text-decoration-color: var(--rd-violet-glow-30);
}
.fd-page a:not([class]):hover {
- color: #c4b5fd;
- text-decoration-color: #8b5cf6;
+ color: var(--rd-violet-lighter);
+ text-decoration-color: var(--rd-violet);
}
/* Marketing page utilities */
@@ -272,5 +289,6 @@ nav[aria-label='Breadcrumb'] a:hover {
.animate-fade-in-up {
animation: none !important;
transform: none !important;
+ transition: none !important;
}
}
diff --git a/apps/web/components/._FaqAccordion.tsx b/apps/web/components/._FaqAccordion.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/._FaqAccordion.tsx and /dev/null differ
diff --git a/apps/web/components/._Footer.tsx b/apps/web/components/._Footer.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/._Footer.tsx and /dev/null differ
diff --git a/apps/web/components/._NavDropdown.tsx b/apps/web/components/._NavDropdown.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/._NavDropdown.tsx and /dev/null differ
diff --git a/apps/web/components/._Navbar.tsx b/apps/web/components/._Navbar.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/._Navbar.tsx and /dev/null differ
diff --git a/apps/web/components/FaqAccordion.tsx b/apps/web/components/FaqAccordion.tsx
index 61d08d55..252e9a85 100644
--- a/apps/web/components/FaqAccordion.tsx
+++ b/apps/web/components/FaqAccordion.tsx
@@ -120,30 +120,40 @@ export default function FaqAccordion(props: Props) {
{/* Category tabs */}
-
- {categories.map(cat => (
-
{
- setActiveTab(cat.category);
- setSearch('');
- }}
- className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
- !isSearching && activeTab === cat.category
- ? 'bg-accent text-white'
- : 'border border-border text-text-secondary hover:bg-white/5 hover:text-white'
- }`}
- >
- {cat.category}
-
- ))}
+
+ {categories.map(cat => {
+ const isActive = !isSearching && activeTab === cat.category;
+ return (
+ {
+ setActiveTab(cat.category);
+ setSearch('');
+ }}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ isActive
+ ? 'bg-accent text-white'
+ : 'border border-border text-text-secondary hover:bg-white/5 hover:text-white'
+ }`}
+ >
+ {cat.category}
+
+ );
+ })}
{/* Results */}
{isSearching && visibleItems.length === 0 ? (
No questions match your search.
) : (
-
+
)}
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index d29d3eac..3c917bd3 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -129,25 +129,11 @@ export default function Footer() {
{/* Bottom bar */}
-
+
© {year} Readied. Built with ♥ in Argentina.
-
diff --git a/apps/web/components/landing/._Audience.tsx b/apps/web/components/landing/._Audience.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/landing/._Audience.tsx and /dev/null differ
diff --git a/apps/web/components/landing/._Features.tsx b/apps/web/components/landing/._Features.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/landing/._Features.tsx and /dev/null differ
diff --git a/apps/web/components/landing/._Hero.tsx b/apps/web/components/landing/._Hero.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/landing/._Hero.tsx and /dev/null differ
diff --git a/apps/web/components/landing/._SocialProof.tsx b/apps/web/components/landing/._SocialProof.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/landing/._SocialProof.tsx and /dev/null differ
diff --git a/apps/web/components/landing/._WhyLocal.tsx b/apps/web/components/landing/._WhyLocal.tsx
deleted file mode 100644
index 87e41598..00000000
Binary files a/apps/web/components/landing/._WhyLocal.tsx and /dev/null differ
diff --git a/apps/web/components/landing/Testimonials.tsx b/apps/web/components/landing/Testimonials.tsx
index aa010341..2c3ffd00 100644
--- a/apps/web/components/landing/Testimonials.tsx
+++ b/apps/web/components/landing/Testimonials.tsx
@@ -56,9 +56,9 @@ const secondRow = reviews.slice(reviews.length / 2);
function Stars({ count }: { count: number }) {
return (
-
+
{Array.from({ length: count }).map((_, i) => (
-
+
))}
);
@@ -101,7 +101,7 @@ function ReviewCard({
{text}
-
{date}
+
{date}
);
}
diff --git a/apps/web/components/landing/WhyLocal.tsx b/apps/web/components/landing/WhyLocal.tsx
index bdac47d3..e1123e78 100644
--- a/apps/web/components/landing/WhyLocal.tsx
+++ b/apps/web/components/landing/WhyLocal.tsx
@@ -194,12 +194,15 @@ function DataFlowDiagram() {
export default function WhyLocal() {
return (
-
+
{/* Header */}
Why Local
-
+
Your notes should live on your machine
{' — '}not someone else's server.
diff --git a/apps/web/components/magicui/animated-beam.tsx b/apps/web/components/magicui/animated-beam.tsx
index b824e4e9..b892a6ac 100644
--- a/apps/web/components/magicui/animated-beam.tsx
+++ b/apps/web/components/magicui/animated-beam.tsx
@@ -1,6 +1,6 @@
'use client';
-import { type RefObject, useEffect, useId, useState } from 'react';
+import { type RefObject, useEffect, useId, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -32,7 +32,7 @@ export const AnimatedBeam: React.FC = ({
toRef,
curvature = 0,
reverse = false,
- duration = Math.random() * 3 + 4,
+ duration: durationProp,
delay = 0,
pathColor = 'gray',
pathWidth = 2,
@@ -45,6 +45,7 @@ export const AnimatedBeam: React.FC = ({
endYOffset = 0,
}) => {
const id = useId();
+ const duration = useMemo(() => durationProp ?? Math.random() * 3 + 4, [durationProp]);
const [pathD, setPathD] = useState('');
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 });
diff --git a/apps/web/components/magicui/animated-grid-pattern.tsx b/apps/web/components/magicui/animated-grid-pattern.tsx
index 777dac64..170c8337 100644
--- a/apps/web/components/magicui/animated-grid-pattern.tsx
+++ b/apps/web/components/magicui/animated-grid-pattern.tsx
@@ -32,8 +32,16 @@ export function AnimatedGridPattern({
}: AnimatedGridPatternProps) {
const id = useId();
const containerRef = useRef(null);
+ const mountedRef = useRef(true);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
const getPos = useCallback(() => {
return [
Math.floor((Math.random() * dimensions.width) / width),
@@ -53,19 +61,22 @@ export function AnimatedGridPattern({
const [squares, setSquares] = useState(() => generateSquares(numSquares));
- // Function to update a single square's position
- const updateSquarePosition = (id: number) => {
- setSquares(currentSquares =>
- currentSquares.map(sq =>
- sq.id === id
- ? {
- ...sq,
- pos: getPos(),
- }
- : sq
- )
- );
- };
+ const updateSquarePosition = useCallback(
+ (id: number) => {
+ if (!mountedRef.current) return;
+ setSquares(currentSquares =>
+ currentSquares.map(sq =>
+ sq.id === id
+ ? {
+ ...sq,
+ pos: getPos(),
+ }
+ : sq
+ )
+ );
+ },
+ [getPos]
+ );
// Update squares to animate in
useEffect(() => {
@@ -78,6 +89,7 @@ export function AnimatedGridPattern({
useEffect(() => {
const currentRef = containerRef.current;
const resizeObserver = new ResizeObserver(entries => {
+ if (!mountedRef.current) return;
for (const entry of entries) {
setDimensions({
width: entry.contentRect.width,
diff --git a/apps/web/components/magicui/dot-pattern.tsx b/apps/web/components/magicui/dot-pattern.tsx
index bfc57468..ca67a2b4 100644
--- a/apps/web/components/magicui/dot-pattern.tsx
+++ b/apps/web/components/magicui/dot-pattern.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { useEffect, useId, useRef, useState } from 'react';
+import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -50,23 +50,27 @@ export function DotPattern({
const safeWidth = Math.max(1, width);
const safeHeight = Math.max(1, height);
- const dots = Array.from(
- {
- length:
- dimensions.width > 0 && dimensions.height > 0
- ? Math.ceil(dimensions.width / safeWidth) * Math.ceil(dimensions.height / safeHeight)
- : 0,
- },
- (_, i) => {
- const col = i % Math.ceil(dimensions.width / safeWidth);
- const row = Math.floor(i / Math.ceil(dimensions.width / safeWidth));
- return {
- x: col * safeWidth + cx + x,
- y: row * safeHeight + cy + y,
- delay: Math.random() * 5,
- duration: Math.random() * 3 + 2,
- };
- }
+ const dots = useMemo(
+ () =>
+ Array.from(
+ {
+ length:
+ dimensions.width > 0 && dimensions.height > 0
+ ? Math.ceil(dimensions.width / safeWidth) * Math.ceil(dimensions.height / safeHeight)
+ : 0,
+ },
+ (_, i) => {
+ const col = i % Math.ceil(dimensions.width / safeWidth);
+ const row = Math.floor(i / Math.ceil(dimensions.width / safeWidth));
+ return {
+ x: col * safeWidth + cx + x,
+ y: row * safeHeight + cy + y,
+ delay: Math.random() * 5,
+ duration: Math.random() * 3 + 2,
+ };
+ }
+ ),
+ [dimensions.width, dimensions.height, safeWidth, safeHeight, cx, cy, x, y]
);
return (
diff --git a/apps/web/components/magicui/hero-video-dialog.tsx b/apps/web/components/magicui/hero-video-dialog.tsx
index d73dc0bf..b941e4ed 100644
--- a/apps/web/components/magicui/hero-video-dialog.tsx
+++ b/apps/web/components/magicui/hero-video-dialog.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { Play, XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
@@ -75,7 +75,23 @@ export function HeroVideoDialog({
className,
}: HeroVideoProps) {
const [isVideoOpen, setIsVideoOpen] = useState(false);
+ const [isClosing, setIsClosing] = useState(false);
const selectedAnimation = animationVariants[animationStyle];
+ const closeButtonRef = useRef(null);
+
+ const closeVideo = useCallback(() => {
+ setIsClosing(true);
+ setIsVideoOpen(false);
+ }, []);
+
+ useEffect(() => {
+ if (!isVideoOpen) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') closeVideo();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isVideoOpen, closeVideo]);
return (
@@ -90,6 +106,7 @@ export function HeroVideoDialog({
alt={thumbnailAlt}
width={1920}
height={1080}
+ loading="lazy"
className="w-full rounded-xl border border-white/[0.06] shadow-2xl shadow-accent/5 transition-all duration-200 ease-out group-hover:brightness-[0.8]"
/>
@@ -106,7 +123,7 @@ export function HeroVideoDialog({
-
+ setIsClosing(false)}>
{isVideoOpen && (
{
- if (e.key === 'Escape') {
- setIsVideoOpen(false);
- }
- }}
- onClick={() => setIsVideoOpen(false)}
+ onClick={closeVideo}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md"
>
@@ -129,16 +141,18 @@ export function HeroVideoDialog({
className="relative mx-4 aspect-video w-full max-w-4xl md:mx-0"
>
setIsVideoOpen(false)}
+ autoFocus
+ onClick={closeVideo}
className="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 ring-white/10 backdrop-blur-md"
>