commit 06fae294b8655becb91300f6ef7fc30405e2ea95 Author: shakespeare.diy Date: Mon Jun 15 19:49:07 2026 +0700 New project created with Shakespeare Co-authored-by: shakespeare.diy diff --git a/.agents/skills/ai-chat/SKILL.md b/.agents/skills/ai-chat/SKILL.md new file mode 100644 index 0000000..b9670e9 --- /dev/null +++ b/.agents/skills/ai-chat/SKILL.md @@ -0,0 +1,359 @@ +--- +name: ai-chat +description: Build AI-powered chat interfaces, implement streaming responses, or integrate with Shakespeare AI. +--- + +# AI Integration with Shakespeare API + +This skill provides the `useShakespeare` hook for AI chat completions with Nostr authentication against the Shakespeare AI API. The API dynamically provides available models, so you should query them at runtime rather than hardcoding model names. + +**The Shakespeare integration is not included in the project by default.** When the user wants AI chat functionality, follow the setup instructions below to install the hook. + +## Files Provided by This Skill + +| Skill file | Copy to | +|---|---| +| `files/hooks/useShakespeare.ts` | `src/hooks/useShakespeare.ts` | + +## Setup Instructions + +### 1. Dependencies + +No extra npm packages are required — the hook uses only packages already present in the template (`react`, `@nostrify/react/login` via the existing `useCurrentUser` hook). + +### 2. Copy the Skill File Into `src/` + +Copy `.agents/skills/ai-chat/files/hooks/useShakespeare.ts` into `src/hooks/useShakespeare.ts`. The hook uses `@/hooks/useCurrentUser` and `@nostrify/react/login` types — both already present in the template — so no further wiring is required. + +## Usage + +Use the `useShakespeare` hook for AI chat completions with Nostr authentication. The API dynamically provides available models, so you should query them at runtime rather than hardcoding model names. + +```tsx +import { useShakespeare, type ChatMessage, type Model } from '@/hooks/useShakespeare'; + +const { + sendChatMessage, + sendStreamingMessage, + getAvailableModels, + isLoading, + error, + isAuthenticated +} = useShakespeare(); +``` + +#### Model Selector Component + +```tsx +function ModelSelector({ onModelSelect }: { onModelSelect: (modelId: string) => void }) { + const { getAvailableModels, isLoading } = useShakespeare(); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + + useEffect(() => { + const fetchModels = async () => { + try { + const response = await getAvailableModels(); + // Sort models by total cost (cheapest first) + const sortedModels = response.data.sort((a, b) => { + const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion); + const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion); + return costA - costB; + }); + setModels(sortedModels); + + // Select the cheapest model by default + if (sortedModels.length > 0) { + const cheapestModel = sortedModels[0]; + setSelectedModel(cheapestModel.id); + onModelSelect(cheapestModel.id); + } + } catch (err) { + console.error('Failed to fetch models:', err); + } + }; + + fetchModels(); + }, [getAvailableModels, onModelSelect]); + + const handleModelChange = (modelId: string) => { + setSelectedModel(modelId); + onModelSelect(modelId); + }; + + return ( +
+ + +
+ ); +} +``` + +#### Basic Chat Example + +```tsx +function AIChat() { + const { sendChatMessage, isLoading, error, isAuthenticated } = useShakespeare(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [selectedModel, setSelectedModel] = useState(''); + + const handleSend = async () => { + if (!input.trim() || !selectedModel) return; + + const newMessages = [...messages, { role: 'user', content: input }]; + setMessages(newMessages); + setInput(''); + + try { + const response = await sendChatMessage(newMessages, selectedModel); + setMessages(prev => [...prev, { + role: 'assistant', + content: response.choices[0].message.content as string + }]); + } catch (err) { + console.error('Chat error:', err); + } + }; + + if (!isAuthenticated) return
Please log in to use AI
; + + return ( +
+ {error &&
{error}
} + + {/* Model Selection */} +
+ +
+ +
+ {messages.map((msg, i) => ( +
+ {msg.role}: {msg.content} +
+ ))} +
+ +
+ setInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSend()} + className="flex-1 p-2 border rounded" + disabled={isLoading || !selectedModel} + placeholder={!selectedModel ? "Select a model first..." : "Type your message..."} + /> + +
+
+ ); +} +``` + +#### Streaming Chat Example + +```tsx +function StreamingChat() { + const { sendStreamingMessage } = useShakespeare(); + const [messages, setMessages] = useState([]); + const [currentResponse, setCurrentResponse] = useState(''); + const [selectedModel, setSelectedModel] = useState(''); + + const handleStreaming = async (content: string) => { + if (!selectedModel) return; + + setCurrentResponse(''); + const newMessages = [...messages, { role: 'user', content }]; + setMessages(newMessages); + + try { + await sendStreamingMessage(newMessages, selectedModel, (chunk) => { + setCurrentResponse(prev => prev + chunk); + }); + + // Add the complete response to messages + if (currentResponse.trim()) { + setMessages(prev => [...prev, { + role: 'assistant', + content: currentResponse + }]); + } + } catch (err) { + console.error('Streaming error:', err); + } finally { + setCurrentResponse(''); + } + }; + + return ( +
+ {/* Model selection UI */} +
+ +
+ + {/* Chat interface */} + {/* ... rest of your chat UI */} +
+ ); +} +``` + +#### Model Information + +Models are dynamically fetched from the Shakespeare API and include: + +- **Model ID**: Unique identifier for the model +- **Name**: Human-readable model name +- **Description**: Model capabilities and use cases +- **Context Window**: Maximum token limit for conversations +- **Pricing**: Cost per token for prompt and completion +- **Free Models**: Models with `pricing.prompt === "0"` and `pricing.completion === "0"` + +#### Key Points + +- **Dynamic Model Discovery**: Always fetch available models using `getAvailableModels()` +- **Authentication Required**: User must be logged in with Nostr account +- **Free vs Premium**: Check pricing to determine if model requires credits +- **Error Handling**: Handle `isLoading` and `error` states appropriately +- **Model Selection**: Provide UI for users to choose between available models + +## Implementation Patterns and Best Practices + +### Dialog Component Patterns + +When using Dialog components, always ensure accessibility compliance by including required elements: + +```tsx +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; + +// ✅ Correct - Always include DialogHeader with DialogTitle + + + + Dialog Title + + Optional description for screen readers + + + {/* Dialog content */} + + +``` + +**Important**: Even if you want to hide the title visually, use the `VisuallyHidden` component to maintain accessibility: + +```tsx +import { VisuallyHidden } from "radix-ui"; + + + + Hidden Title for Screen Readers + + +``` + +### Streaming Response Handling + +When implementing streaming chat interfaces, always accumulate streamed content in a local variable before clearing the streaming state to prevent content loss: + +```tsx +const handleStreamingResponse = async () => { + let streamedContent = ''; // ✅ Use local variable to accumulate content + + try { + await sendStreamingMessage(messages, model, (chunk) => { + streamedContent += chunk; // ✅ Accumulate in local variable + setCurrentStreamingMessage(streamedContent); // Update UI + }); + + // ✅ Save accumulated content to persistent state + if (streamedContent.trim()) { + const assistantMessage: MessageDisplay = { + id: Date.now().toString(), + role: 'assistant', + content: streamedContent, // ✅ Use accumulated content + timestamp: new Date() + }; + setMessages(prev => [...prev, assistantMessage]); + } + } finally { + setCurrentStreamingMessage(''); // ✅ Clear streaming state after saving + } +}; +``` + +### Error Boundary Patterns + +Always wrap AI components with error boundaries and provide user-friendly error messages for common failure scenarios: + +```tsx +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +function AIChatWithErrorBoundary() { + return ( + + + + + Something went wrong with the AI chat. Please refresh the page and try again. + + + + } + > + + + ); +} + +// In your AI component, handle specific error types gracefully: +function useAIWithErrorHandling() { + const { sendChatMessage, error, clearError } = useShakespeare(); + + const sendMessage = async (messages: ChatMessage[], modelId: string) => { + try { + await sendChatMessage(messages, modelId); + } catch (err) { + // Handle specific error types with user-friendly messages + if (err.message.includes('401')) { + throw new Error('Authentication failed. Please log in again.'); + } else if (err.message.includes('402')) { + throw new Error('Insufficient credits. Please add credits to use premium features.'); + } else if (err.message.includes('network')) { + throw new Error('Network error. Please check your internet connection.'); + } + throw err; // Re-throw for error boundary + } + }; + + return { sendMessage, error, clearError }; +} +``` diff --git a/.agents/skills/ai-chat/files/hooks/useShakespeare.ts b/.agents/skills/ai-chat/files/hooks/useShakespeare.ts new file mode 100644 index 0000000..c29c3ee --- /dev/null +++ b/.agents/skills/ai-chat/files/hooks/useShakespeare.ts @@ -0,0 +1,372 @@ +import { useCallback, useState } from 'react'; +import { useCurrentUser } from './useCurrentUser'; +import type { NUser } from '@nostrify/react/login'; + +// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam) +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string | Array<{ + type: 'text' | 'image_url'; + text?: string; + image_url?: { + url: string; + }; + }>; +} + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + stream?: boolean; + temperature?: number; + max_tokens?: number; +} + +export interface ChatCompletionResponse { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: ChatMessage; + finish_reason: string; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface Model { + id: string; + name: string; + description: string; + object: string; + owned_by: string; + created: number; + context_window: number; + pricing: { + prompt: string; + completion: string; + }; +} + +export interface ModelsResponse { + object: string; + data: Model[]; +} + +// Configuration +const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1'; + +// Helper function to create NIP-98 token +async function createNIP98Token( + method: string, + url: string, + body?: unknown, + user?: NUser +): Promise { + if (!user?.signer) { + throw new Error('User signer is required for NIP-98 authentication'); + } + + // Create the tags array + const tags: string[][] = [ + ['u', url], + ['method', method] + ]; + + // Add payload hash for requests with body (following NIP-98 spec) + if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + const bodyString = JSON.stringify(body); + const encoder = new TextEncoder(); + const data = encoder.encode(bodyString); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const payloadHash = Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + tags.push(['payload', payloadHash]); + } + + // Create the HTTP request event + const event = await user.signer.signEvent({ + kind: 27235, // NIP-98 HTTP Auth + content: '', + tags, + created_at: Math.floor(Date.now() / 1000) + }); + + // Return the token (base64 encoded event) + return btoa(JSON.stringify(event)); +} + +// Helper function to handle API errors with user-friendly messages +async function handleAPIError(response: Response) { + if (response.status === 401) { + throw new Error('Authentication failed. Please make sure you are logged in with a Nostr account.'); + } else if (response.status === 402) { + throw new Error('Insufficient credits. Please add credits to your account to use premium models, or use the free "tybalt" model.'); + } else if (response.status === 400) { + try { + const error = await response.json(); + if (error.error?.type === 'invalid_request_error') { + // Handle specific validation errors + if (error.error.code === 'minimum_amount_not_met') { + throw new Error(`Minimum credit amount is $${error.error.minimum_amount}. Please increase your payment amount.`); + } else if (error.error.code === 'unsupported_method') { + throw new Error('Payment method not supported. Please use "stripe" or "lightning".'); + } else if (error.error.code === 'invalid_url') { + throw new Error('Invalid redirect URL provided for Stripe payment.'); + } + } + throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`); + } catch { + throw new Error('Invalid request. Please check your parameters and try again.'); + } + } else if (response.status === 404) { + throw new Error('Resource not found. Please check the payment ID or try again.'); + } else if (response.status >= 500) { + throw new Error('Server error. Please try again in a few moments.'); + } else if (!response.ok) { + try { + const errorData = await response.json(); + throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`); + } catch { + throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`); + } + } +} + +export function useShakespeare() { + const { user } = useCurrentUser(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Clear error helper + const clearError = useCallback(() => { + setError(null); + }, []); + + // Chat completion function + const sendChatMessage = useCallback(async ( + messages: ChatMessage[], + model: string = 'shakespeare', + options?: Partial + ): Promise => { + if (!user) { + throw new Error('User must be logged in to use AI features'); + } + + setIsLoading(true); + setError(null); + + try { + const requestBody: ChatCompletionRequest = { + model, + messages, + ...options + }; + + const token = await createNIP98Token( + 'POST', + `${SHAKESPEARE_API_URL}/chat/completions`, + requestBody, + user + ); + + const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Nostr ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + await handleAPIError(response); + return await response.json(); + } catch (err) { + let errorMessage = 'An unexpected error occurred'; + + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } + + // Add context for common issues + if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { + errorMessage = 'Network error: Please check your internet connection and try again.'; + } else if (errorMessage.includes('signer')) { + errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; + } + + setError(errorMessage); + throw new Error(errorMessage, { cause: err }); + } finally { + setIsLoading(false); + } + }, [user]); + + // Streaming chat completion function + const sendStreamingMessage = useCallback(async ( + messages: ChatMessage[], + model: string = 'shakespeare', + onChunk: (chunk: string) => void, + options?: Partial + ): Promise => { + if (!user) { + throw new Error('User must be logged in to use AI features'); + } + + setIsLoading(true); + setError(null); + + try { + const requestBody: ChatCompletionRequest = { + model, + messages, + stream: true, + ...options + }; + + const token = await createNIP98Token( + 'POST', + `${SHAKESPEARE_API_URL}/chat/completions`, + requestBody, + user + ); + + const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Nostr ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + await handleAPIError(response); + + if (!response.body) { + throw new Error('No response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') return; + + try { + const parsed = JSON.parse(data); + const content = parsed.choices?.[0]?.delta?.content; + if (content) { + onChunk(content); + } + } catch { + // Ignore parsing errors for incomplete chunks + } + } + } + } + } finally { + reader.releaseLock(); + } + } catch (err) { + let errorMessage = 'An unexpected error occurred'; + + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } + + // Add context for common issues + if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { + errorMessage = 'Network error: Please check your internet connection and try again.'; + } else if (errorMessage.includes('signer')) { + errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; + } + + setError(errorMessage); + throw new Error(errorMessage, { cause: err }); + } finally { + setIsLoading(false); + } + }, [user]); + + // Get available models + const getAvailableModels = useCallback(async (): Promise => { + if (!user) { + throw new Error('User must be logged in to use AI features'); + } + + setIsLoading(true); + setError(null); + + try { + const token = await createNIP98Token( + 'GET', + `${SHAKESPEARE_API_URL}/models`, + undefined, + user + ); + + const response = await fetch(`${SHAKESPEARE_API_URL}/models`, { + method: 'GET', + headers: { + 'Authorization': `Nostr ${token}`, + }, + }); + + await handleAPIError(response); + return await response.json(); + } catch (err) { + let errorMessage = 'An unexpected error occurred'; + + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } + + // Add context for common issues + if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { + errorMessage = 'Network error: Please check your internet connection and try again.'; + } else if (errorMessage.includes('signer')) { + errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; + } + + setError(errorMessage); + throw new Error(errorMessage, { cause: err }); + } finally { + setIsLoading(false); + } + }, [user]); + + return { + // State + isLoading, + error, + isAuthenticated: !!user, + + // Actions + sendChatMessage, + sendStreamingMessage, + getAvailableModels, + clearError, + }; +} diff --git a/.agents/skills/capacitor/SKILL.md b/.agents/skills/capacitor/SKILL.md new file mode 100644 index 0000000..8aed38c --- /dev/null +++ b/.agents/skills/capacitor/SKILL.md @@ -0,0 +1,184 @@ +--- +name: capacitor +description: Wrap the mkstack web app as a native iOS and Android application using Capacitor. Provides haptics, native file downloads / share sheet, OS-level secure storage (Keychain / KeyStore), deep-link routing, status-bar theme sync, and safe-area CSS utilities. +--- + +# Capacitor Native Wrapper + +This skill turns the web app into a native iOS and Android binary using [Capacitor](https://capacitorjs.com/) — no Swift or Kotlin required for the basics. The React UI runs unchanged inside a native WebView; the helpers below provide the cross-platform primitives that make it feel native. + +**Not included in the project by default.** Run the setup below when the user wants to ship to an app store or produce an `.apk` / `.ipa`. + +## What this skill provides + +| Capability | Web behavior | Native behavior | +|---|---|---| +| **Haptics** (`impactLight`, `notificationSuccess`, …) | `navigator.vibrate()` on Android browsers | Taptic engine / Android haptics | +| **`downloadTextFile(filename, content)`** | `` click | Writes to app Documents directory | +| **`openUrl(url)`** | `window.open(url, '_blank')` | Native share sheet | +| **`secureStorage` / `useSecureLocalStorage`** | `localStorage` | iOS Keychain / Android KeyStore, auto-migrates plaintext values | +| **``** | no-op | Forwards OS `appUrlOpen` into React Router | +| **`bootstrapNative()`** | no-op | Hides iOS keyboard accessory bar; syncs system-bar icon style with theme | +| **Safe-area utilities** (Tailwind) | `env(safe-area-inset-*)` | `env(safe-area-inset-*)` + `SystemBars` back-fill on Android | + +Every helper is SSR-safe and web-safe — import and call unconditionally from shared components. Each source file ships with a JSDoc header covering the exact API, gotchas, and usage examples; read them after copying. + +## Files to copy + +Copy from `.agents/skills/capacitor/files/` into the matching project location: + +| Skill file | Copy to | +|---|---| +| `files/lib/haptics.ts` | `src/lib/haptics.ts` | +| `files/lib/downloadFile.ts` | `src/lib/downloadFile.ts` | +| `files/lib/secureStorage.ts` | `src/lib/secureStorage.ts` | +| `files/lib/nativeBootstrap.ts` | `src/lib/nativeBootstrap.ts` | +| `files/hooks/useSecureLocalStorage.ts` | `src/hooks/useSecureLocalStorage.ts` | +| `files/components/DeepLinkHandler.tsx` | `src/components/DeepLinkHandler.tsx` | +| `files/capacitor.config.ts` | `capacitor.config.ts` (project root — edit `appId`, `appName`, `scheme`, background colors) | +| `files/scripts/patch-cap-config.mjs` | `scripts/patch-cap-config.mjs` — only needed if you add **local** (non-SPM) native plugin classes; otherwise skip | +| `files/safe-area-shim.css` | `src/safe-area-shim.css` — **only if** you need Android WebView <140 support (see shim file header and troubleshooting below) | + +## Setup + +### 1. Install + +```bash +# Runtime + plugins +npm install @capacitor/core @capacitor/app @capacitor/filesystem \ + @capacitor/haptics @capacitor/keyboard @capacitor/share \ + capacitor-secure-storage-plugin + +# Toolchain +npm install -D @capacitor/cli @capacitor/android @capacitor/ios + +# Safe-area utilities (Tailwind v4 — this project) +npm install -D tailwindcss-safe-area +``` + +mkstack ships **Tailwind v4** with CSS-first config (`@import "tailwindcss"` in `src/index.css`, no `tailwind.config.ts`). If you're adapting these instructions to a Tailwind v3 project, install `tailwindcss-safe-area@0.8.0` instead and register it in `tailwind.config.ts`'s `plugins` array — `1.x` emits no utilities on v3. + +### 2. Copy the skill files + +Copy every file from the table above. + +### 3. Register the safe-area plugin + +Add a second `@import` to `src/index.css`, right after the existing `@import "tailwindcss";`: + +```css +@import "tailwindcss"; +@import "tailwindcss-safe-area"; +@import "tw-animate-css"; +``` + +**Use `@import`, not `@plugin`.** The package ships a plain CSS file for Tailwind v4, not a JS plugin entry point. + +### 4. Fill in `capacitor.config.ts` + +Edit `appId` (reverse-DNS; **cannot be changed after publishing**), `appName`, `ios.scheme`, and the iOS/Android `backgroundColor` values. All other defaults in the skill's config are production-safe. + +### 5. Update `index.html` + +iOS won't expose `env(safe-area-inset-*)` without `viewport-fit=cover`: + +```html + +``` + +### 6. Wire up bootstrap and deep links + +Call `bootstrapNative()` **before** React mounts, so system bars are themed at first paint: + +```tsx +// src/main.tsx +import { bootstrapNative } from '@/lib/nativeBootstrap'; +bootstrapNative(); +createRoot(document.getElementById('root')!).render(); +``` + +Render `` **inside** `` so `useNavigate()` works: + +```tsx +// src/AppRouter.tsx + + + + {/* … */} + +``` + +### 7. Add platforms and build + +```bash +npx cap add android +npx cap add ios # macOS only + +npm run build # produces dist/ +npx cap sync # copies dist/ into android/ and ios/ + +npx cap open android # Android Studio; hit Run +npx cap open ios # Xcode (macOS only); hit Run +``` + +Commit the generated `android/` and `ios/` directories (signing keys stay excluded via the generated `.gitignore`s). If you later add a **local** (non-SPM) Capacitor plugin class, append `node scripts/patch-cap-config.mjs` to your sync command so `packageClassList` survives each `npx cap sync`. + +## Using the APIs + +Each file's JSDoc header has full usage details. Quick tour: + +```tsx +import { impactLight, notificationSuccess } from '@/lib/haptics'; +import { downloadTextFile, openUrl } from '@/lib/downloadFile'; +import { secureStorage } from '@/lib/secureStorage'; +import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage'; + +impactLight(); // fire-and-forget +notificationSuccess(); // silent no-op on unsupported platforms + +await downloadTextFile('export.json', JSON.stringify(data)); +await openUrl('https://example.com'); + +await secureStorage.setItem('nwc:active', conn); +await secureStorage.getItem('nwc:active'); + +const [conn, setConn, ready] = useSecureLocalStorage('nwc:active', null); +if (!ready) return ; // native reads are async +``` + +## Safe-area utilities + +Once step 3 is done, `*-safe` utilities are available for padding, margin, position, height, border, and scroll properties. The most useful ones: + +- `pt-safe`, `pb-safe`, `px-safe`, `py-safe`, `p-safe` (also logical `ps-`/`pe-` and `start-`/`end-` positions) +- `top-safe`, `bottom-safe`, `inset-safe`, `inset-x-safe` +- `h-dvh-safe`, `min-h-dvh-safe`, `h-svh-safe`, `h-lvh-safe`, `h-screen-safe` +- `{prop}-safe-offset-{n}` — safe area **plus** `{n}` (`pb-safe-offset-4` = inset + 1rem) +- `{prop}-safe-or-{n}` — `max(safe-area, {n})` (`pb-safe-or-8` = at least 2rem) + +```tsx +
…
+ +… +
…
+``` + +Full list: [plugin README](https://github.com/mvllow/tailwindcss-safe-area). + +## Common follow-ups + +- **App icons / splash screen** — standard icon generator → `android/app/src/main/res/mipmap-*/` and `ios/App/App/Assets.xcassets/AppIcon.appiconset/`. For splash, add `@capacitor/splash-screen` and configure under `plugins.SplashScreen`. +- **Push notifications** — add `@capacitor/push-notifications` (FCM / APNs); the web side keeps using Web Push. +- **Deep-link verification** — host `apple-app-site-association` and `assetlinks.json` on your domain so the OS opens verified `https://` links directly. Full checklist in the `DeepLinkHandler.tsx` header. +- **Nsec autofill via iCloud Keychain / Credential Manager** — add `@capgo/capacitor-autofill-save-password` and wrap it analogously to `secureStorage`. + +## Troubleshooting + +- **Safe-area utilities render as `0` on Android** — Chromium WebView <140 reports `env(safe-area-inset-*)` as `0` ([bug 40699457](https://issues.chromium.org/issues/40699457)). `SystemBars` (`insetsHandling: 'css'`, already set) injects `--safe-area-inset-*` CSS variables, but `tailwindcss-safe-area` uses `env(…)` directly. **Preferred fix:** target WebView 140+ (Aug 2025; auto-updated on Android ≥7). **Fallback:** copy `files/safe-area-shim.css` to `src/` and `@import` it **after** `@import "tailwindcss-safe-area";` so the variables take over when `env()` is 0. +- **`pt-safe` doesn't exist** — either `@import "tailwindcss-safe-area";` is missing / in the wrong position (must come **after** `@import "tailwindcss";`), or you installed `0.8.0` (v3-only) on a v4 project. `npm ls tailwindcss-safe-area` should show `>=1.0.0`. +- **Safe-area is 0 on iOS Safari / simulator** — missing `viewport-fit=cover` in the `` tag. +- **Deep links don't navigate** — `` must be **inside** ``; `useNavigate()` throws silently otherwise. +- **iOS keyboard still shows the accessory bar** — `bootstrapNative()` must run **before** any `` is focused. Invoke at the top of `main.tsx`. + +Remaining edge cases (`secureStorage` returning `null` after debug reinstall, status-bar theming with custom CSS-variable setups) are covered in the corresponding file headers. diff --git a/.agents/skills/capacitor/files/capacitor.config.ts b/.agents/skills/capacitor/files/capacitor.config.ts new file mode 100644 index 0000000..404ee98 --- /dev/null +++ b/.agents/skills/capacitor/files/capacitor.config.ts @@ -0,0 +1,40 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +// ── Fill in your app identity before running `npx cap add android` / `ios`. ── +// The appId must be a reverse-DNS-style identifier (e.g. `com.example.myapp`) +// and cannot be changed later without re-registering the app with the stores. + +const config: CapacitorConfig = { + appId: 'com.example.myapp', + appName: 'MyApp', + webDir: 'dist', + server: { + androidScheme: 'https', + iosScheme: 'https', + }, + android: { + // Disallow loading mixed-content (http:// inside https://). + allowMixedContent: false, + // Background color shown behind the web view before first paint. + // Usually your app's dark-theme background so the splash feels seamless. + backgroundColor: '#14161f', + }, + ios: { + backgroundColor: '#14161f', + // `never` prevents WKWebView from inset-adjusting scroll content — the + // app handles safe-area insets itself in CSS via env(safe-area-inset-*). + contentInset: 'never', + // Custom URL scheme for deep-links (e.g. `myapp://…`) — match this + // in your Info.plist CFBundleURLSchemes. + scheme: 'MyApp', + }, + plugins: { + SystemBars: { + // Inject --safe-area-inset-* CSS variables on Android to work around + // a Chromium bug (<140) where env(safe-area-inset-*) reports 0. + insetsHandling: 'css', + }, + }, +}; + +export default config; diff --git a/.agents/skills/capacitor/files/components/DeepLinkHandler.tsx b/.agents/skills/capacitor/files/components/DeepLinkHandler.tsx new file mode 100644 index 0000000..ceba475 --- /dev/null +++ b/.agents/skills/capacitor/files/components/DeepLinkHandler.tsx @@ -0,0 +1,83 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Capacitor } from '@capacitor/core'; + +/** + * Forwards OS-level deep-link opens into the React Router navigation stack. + * + * When the OS launches your app via a URL (a `https://your-domain.com/post/123` + * universal link or a `myapp://post/123` custom-scheme link), Capacitor fires + * an `appUrlOpen` event. This component listens for it and calls + * `navigate(pathname + search + hash)` so the app lands on the intended + * in-app route instead of staying on whatever page it was on. + * + * Must be rendered **inside** a `` (so `useNavigate` works). + * Safe to render unconditionally — on web it is a no-op. + * + * To enable deep links: + * + * **iOS (Universal Links):** + * 1. Add your domain under *Signing & Capabilities → Associated Domains* + * as `applinks:your-domain.com` + * 2. Host an `apple-app-site-association` file at + * `https://your-domain.com/.well-known/apple-app-site-association` + * + * **iOS (custom scheme):** + * 1. Set `ios.scheme` in `capacitor.config.ts` + * 2. Capacitor already registers the scheme in Info.plist's + * `CFBundleURLSchemes` during `npx cap sync` + * + * **Android (App Links):** + * 1. Declare an `` with your + * domain in `android/app/src/main/AndroidManifest.xml` + * 2. Host a `assetlinks.json` file at + * `https://your-domain.com/.well-known/assetlinks.json` + * + * **Android (custom scheme):** + * 1. Add an `` with `` in + * the manifest + * + * @example + * // In AppRouter.tsx, inside BrowserRouter: + * + * + * + * ... + * + */ +export function DeepLinkHandler() { + const navigate = useNavigate(); + + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + + let cleanup: (() => void) | undefined; + + async function setup() { + const { App } = await import('@capacitor/app'); + + // Handle URLs opened while the app is already running + const listener = await App.addListener('appUrlOpen', (event) => { + try { + const url = new URL(event.url); + const path = url.pathname + url.search + url.hash; + if (path) { + navigate(path); + } + } catch { + // Invalid URL, ignore + } + }); + + cleanup = () => listener.remove(); + } + + setup(); + + return () => { + cleanup?.(); + }; + }, [navigate]); + + return null; +} diff --git a/.agents/skills/capacitor/files/hooks/useSecureLocalStorage.ts b/.agents/skills/capacitor/files/hooks/useSecureLocalStorage.ts new file mode 100644 index 0000000..a835893 --- /dev/null +++ b/.agents/skills/capacitor/files/hooks/useSecureLocalStorage.ts @@ -0,0 +1,158 @@ +import { useState, useEffect, useRef } from 'react'; +import { Capacitor } from '@capacitor/core'; +import { secureStorage } from '@/lib/secureStorage'; + +/** + * Hook for storing sensitive state that should not live in plaintext + * localStorage on native platforms. + * + * - On web: uses `localStorage` (identical to `useLocalStorage`). + * - On native (Capacitor iOS/Android): uses the native Keychain / KeyStore via + * `secureStorage`. Any existing plaintext value in localStorage for the same + * key is migrated on first read and the plaintext copy is removed. + * + * This is async under the hood, so the hook additionally exposes a `ready` + * flag indicating whether the initial load has completed. While `!ready`, the + * state is `defaultValue` and callers should avoid making decisions based on + * an "empty" state (e.g. do not persist a default back if the user has real + * data stored that hasn't been loaded yet). + * + * The return tuple is `[state, setValue, ready]`. + * + * @example + * const [connection, setConnection, ready] = useSecureLocalStorage( + * 'nwc:active', + * null, + * ); + * if (!ready) return ; + * // ... + */ +export function useSecureLocalStorage( + key: string, + defaultValue: T, + serializer?: { + serialize: (value: T) => string; + deserialize: (value: string) => T; + }, +) { + const serialize = serializer?.serialize || JSON.stringify; + const deserialize = serializer?.deserialize || JSON.parse; + + const isNative = Capacitor.isNativePlatform(); + + // On web we can read synchronously during initialization (same behavior as + // useLocalStorage). On native we must wait for the async read, so we start + // with defaultValue and flip `ready` once loaded. + const [state, setState] = useState(() => { + if (isNative) return defaultValue; + try { + const item = localStorage.getItem(key); + return item ? deserialize(item) : defaultValue; + } catch (error) { + console.warn(`Failed to load ${key} from localStorage:`, error); + return defaultValue; + } + }); + + const [ready, setReady] = useState(!isNative); + + // Track the most-recently-requested key so stale async reads don't clobber + // state after the caller swapped to a different key. + const currentKeyRef = useRef(key); + useEffect(() => { + currentKeyRef.current = key; + }, [key]); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + if (isNative) { + // Check secureStorage first (handles migration from plaintext + // localStorage internally per secureStorage.getItem). + const item = await secureStorage.getItem(key); + if (cancelled || currentKeyRef.current !== key) return; + setState(item ? deserialize(item) : defaultValue); + } else { + const item = localStorage.getItem(key); + if (cancelled || currentKeyRef.current !== key) return; + setState(item ? deserialize(item) : defaultValue); + } + } catch (error) { + if (!cancelled) { + console.warn(`Failed to load ${key} from secure storage:`, error); + setState(defaultValue); + } + } finally { + if (!cancelled) setReady(true); + } + } + + // Reset `ready` before kicking off the new async load — this is a + // deliberate external-system sync pattern. + // eslint-disable-next-line react-hooks/set-state-in-effect + setReady(false); + load(); + + return () => { + cancelled = true; + }; + // defaultValue and deserialize are intentionally excluded — we only want to + // re-read when the key identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, isNative]); + + const setValue = (value: T | ((prev: T) => T)) => { + const persist = (next: T) => { + try { + const serialized = serialize(next); + if (isNative) { + // Fire-and-forget; errors are logged but shouldn't block the caller. + void secureStorage.setItem(key, serialized).catch((error) => { + console.warn(`Failed to save ${key} to secure storage:`, error); + }); + } else { + localStorage.setItem(key, serialized); + } + } catch (error) { + console.warn(`Failed to serialize ${key}:`, error); + } + }; + + if (value instanceof Function) { + setState((prev) => { + const next = (value as (p: T) => T)(prev); + if (next === prev) return prev; + persist(next); + return next; + }); + } else { + setState((prev) => { + if (value === prev) return prev; + persist(value); + return value; + }); + } + }; + + // Sync with cross-tab changes on web. Native secure storage has no + // cross-tab concept. + useEffect(() => { + if (isNative) return; + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + try { + setState(deserialize(e.newValue)); + } catch (error) { + console.warn(`Failed to sync ${key} from localStorage:`, error); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [key, isNative, deserialize]); + + return [state, setValue, ready] as const; +} diff --git a/.agents/skills/capacitor/files/lib/downloadFile.ts b/.agents/skills/capacitor/files/lib/downloadFile.ts new file mode 100644 index 0000000..3828ff1 --- /dev/null +++ b/.agents/skills/capacitor/files/lib/downloadFile.ts @@ -0,0 +1,60 @@ +import { Capacitor } from '@capacitor/core'; + +/** + * Download a text file to the user's device. + * + * On the web this uses the classic `
` trick. + * On native (Android & iOS) the file is saved to the app's Documents + * directory, which is visible in the iOS Files app and Android's + * app-scoped documents. No permissions are required. + * + * @example + * await downloadTextFile('backup.json', JSON.stringify(data)); + */ +export async function downloadTextFile(filename: string, content: string): Promise { + if (Capacitor.isNativePlatform()) { + const { Filesystem, Directory, Encoding } = await import('@capacitor/filesystem'); + + // Write straight to Documents — visible in the iOS Files app and + // Android's app-scoped documents. No storage permissions needed. + // NOTE: encoding is required — without it Capacitor expects base64 data + // and will throw for plain-text strings. + await Filesystem.writeFile({ + path: filename, + data: content, + directory: Directory.Documents, + encoding: Encoding.UTF8, + }); + } else { + // Web: use the anchor-click download pattern + const blob = new Blob([content], { type: 'text/plain; charset=utf-8' }); + const url = globalThis.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + } +} + +/** + * Open a URL in a new browser tab, or present the native share sheet on Capacitor. + * + * The programmatic `` click pattern doesn't work inside + * WKWebView on iOS. On native platforms this presents the share sheet instead, + * letting the user open, save, or share the resource. + * + * @example + * + */ +export async function openUrl(url: string): Promise { + if (Capacitor.isNativePlatform()) { + const { Share } = await import('@capacitor/share'); + await Share.share({ url }); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } +} diff --git a/.agents/skills/capacitor/files/lib/haptics.ts b/.agents/skills/capacitor/files/lib/haptics.ts new file mode 100644 index 0000000..ba2a9a6 --- /dev/null +++ b/.agents/skills/capacitor/files/lib/haptics.ts @@ -0,0 +1,125 @@ +import { Capacitor } from '@capacitor/core'; + +/** + * Centralized haptic feedback utility. + * + * On native (iOS/Android) it uses @capacitor/haptics for true taptic engine + * feedback. On web it falls back to navigator.vibrate() which works on + * Android browsers but is a silent no-op elsewhere. + * + * Each helper is fire-and-forget: errors are swallowed (logged as warnings) + * so calling haptics from UI handlers never disrupts user actions. + * + * @example + * import { impactLight, notificationSuccess } from '@/lib/haptics'; + * + * + */ + +type ImpactStyle = 'Heavy' | 'Medium' | 'Light'; +type NotificationType = 'Success' | 'Warning' | 'Error'; + +// Lazy-loaded Haptics plugin — only imported on native to avoid bundling +// the plugin in web builds where it isn't useful. +let hapticsPromise: Promise | null = null; + +function getHaptics() { + if (!hapticsPromise) { + hapticsPromise = import('@capacitor/haptics'); + } + return hapticsPromise; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +async function nativeImpact(style: ImpactStyle) { + const { Haptics, ImpactStyle } = await getHaptics(); + await Haptics.impact({ style: ImpactStyle[style] }); +} + +async function nativeNotification(type: NotificationType) { + const { Haptics, NotificationType } = await getHaptics(); + await Haptics.notification({ type: NotificationType[type] }); +} + +async function nativeSelectionChanged() { + const { Haptics } = await getHaptics(); + await Haptics.selectionChanged(); +} + +function vibrate(ms: number) { + try { + navigator.vibrate?.(ms); + } catch { + /* Vibration API not available */ + } +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +function warnHapticError(label: string, err: unknown) { + console.warn(`[haptics] ${label} failed:`, err); +} + +/** Light tap — reactions, reposts, bookmarks, share. */ +export function impactLight(): void { + if (Capacitor.isNativePlatform()) { + nativeImpact('Light').catch((e) => warnHapticError('impactLight', e)); + } else { + vibrate(10); + } +} + +/** Medium tap — zap button press, pull-to-refresh threshold, follow. */ +export function impactMedium(): void { + if (Capacitor.isNativePlatform()) { + nativeImpact('Medium').catch((e) => warnHapticError('impactMedium', e)); + } else { + vibrate(20); + } +} + +/** Heavy tap — game button press, destructive confirmation. */ +export function impactHeavy(): void { + if (Capacitor.isNativePlatform()) { + nativeImpact('Heavy').catch((e) => warnHapticError('impactHeavy', e)); + } else { + vibrate(30); + } +} + +/** Success notification — zap payment success, post published. */ +export function notificationSuccess(): void { + if (Capacitor.isNativePlatform()) { + nativeNotification('Success').catch((e) => warnHapticError('notificationSuccess', e)); + } else { + vibrate(15); + } +} + +/** Warning notification. */ +export function notificationWarning(): void { + if (Capacitor.isNativePlatform()) { + nativeNotification('Warning').catch((e) => warnHapticError('notificationWarning', e)); + } else { + vibrate(20); + } +} + +/** Error notification. */ +export function notificationError(): void { + if (Capacitor.isNativePlatform()) { + nativeNotification('Error').catch((e) => warnHapticError('notificationError', e)); + } else { + vibrate(30); + } +} + +/** Selection changed — toggle switches, tab taps, picker changes. */ +export function selectionChanged(): void { + if (Capacitor.isNativePlatform()) { + nativeSelectionChanged().catch((e) => warnHapticError('selectionChanged', e)); + } else { + vibrate(5); + } +} diff --git a/.agents/skills/capacitor/files/lib/nativeBootstrap.ts b/.agents/skills/capacitor/files/lib/nativeBootstrap.ts new file mode 100644 index 0000000..4065472 --- /dev/null +++ b/.agents/skills/capacitor/files/lib/nativeBootstrap.ts @@ -0,0 +1,121 @@ +import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core'; + +/** + * One-time Capacitor-native bootstrap: configures system chrome to match + * the active CSS theme and hides the iOS keyboard accessory bar. + * + * Call this from `src/main.tsx` **before** `createRoot(...).render(...)`. + * On web this is a no-op. + * + * What it does on native: + * + * 1. Hides the iOS keyboard accessory bar (the prev/next/done toolbar above + * the keyboard) — most apps don't need it and it steals screen space. + * + * 2. Keeps the native system bar icon style (clock, battery, etc.) in sync + * with the active CSS theme. When the app is on a dark background, icons + * are rendered light; on a light background, dark. This is re-applied + * whenever: + * - the `class` on `` changes (light / dark / any custom theme + * that toggles a class, e.g. your AppProvider) + * - the contents of `