diff --git a/.cursor/rules/mobile-drawer-menus.mdc b/.cursor/rules/mobile-drawer-menus.mdc
index eb4beea..4662d8f 100644
--- a/.cursor/rules/mobile-drawer-menus.mdc
+++ b/.cursor/rules/mobile-drawer-menus.mdc
@@ -32,7 +32,10 @@ Standalone single-field edit (no stack): `MobileFormDrawer`.
- **Back** from a field screen: always dismiss via `MobileDrawerFieldView` (default) or `useMobileDrawerLeave()` so the previous view does not resize against a shifting visual viewport.
- **Async save then navigate**: `dismissKeyboardOnDone={false}` on `MobileDrawerFieldView`, then `leave(onSaved)` after success (`useMobileDrawerLeave`).
-- **Autofocus**: built into `MobileDrawerFieldView` (first `input`/`textarea`). Prefer that over manual focus + timeouts.
+- **Autofocus / iOS keyboard**: Safari only opens the keyboard when focus runs in the **same user-gesture turn** as the tap. The kit handles this when you:
+ - Drill into a `keyboardView` via `stage.goToView` (flushSync + `MobileDrawerFieldView` layout-effect focus), or
+ - Open a controlled field drawer with `runWithMobileDrawerOpenSync(() => setOpen(true))` then `focusMobileDrawerInput(ref.current)` (see `document-breadcrumb.tsx`).
+ Do **not** rely on `useEffect`, `requestAnimationFrame`, or animation-complete handlers to open the keyboard.
## Do not
@@ -49,6 +52,11 @@ mainMeasureRef.current ?? stageRef.current;
// ❌ Navigate back while keyboard is up without wait/blur
onBack={stage.returnToView("profile")};
+
+// ❌ Deferred focus — focuses the input on device but does not open the keyboard
+useEffect(() => {
+ requestAnimationFrame(() => inputRef.current?.focus());
+}, [open]);
```
```tsx
@@ -59,6 +67,10 @@ useMobileDrawerStage({ view, setView, mainView: "main", keyboardView: KEYBOARD_V
stage.returnToView("profile")} onDone={…}>
+
+// ✅ Controlled single-field open (standalone drawer)
+runWithMobileDrawerOpenSync(() => setOpen(true));
+focusMobileDrawerInput(inputRef.current);
```
## Wiring checklist
diff --git a/src/app/_components/app-sidebar.tsx b/src/app/_components/app-sidebar.tsx
index 575d3bc..0389678 100644
--- a/src/app/_components/app-sidebar.tsx
+++ b/src/app/_components/app-sidebar.tsx
@@ -24,6 +24,7 @@ import { MobileActionGroup } from "./mobile-action-rows";
import { useCommandMenuStore } from "~/hooks/use-command-menu";
import { useUserProfile } from "~/hooks/use-user-profile";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
+import { randomUUID } from "~/lib/utils";
interface AppSidebarProps extends React.ComponentProps {
initialDocuments: { id: string; name: string }[];
@@ -86,7 +87,7 @@ export function AppSidebar({ initialDocuments, ...props }: AppSidebarProps) {
// Instant document creation with optimistic sidebar update
const handleCreateDocument = React.useCallback(() => {
- const newId = crypto.randomUUID();
+ const newId = randomUUID();
// Mark as new for the document page
markDocumentAsNew(newId);
diff --git a/src/app/_components/command-menu.tsx b/src/app/_components/command-menu.tsx
index 8aa4b6b..e423871 100644
--- a/src/app/_components/command-menu.tsx
+++ b/src/app/_components/command-menu.tsx
@@ -17,6 +17,7 @@ import { DialogTitle } from "./dialog";
import { useCommandMenuStore } from "~/hooks/use-command-menu";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
import { useTheme } from "next-themes";
+import { randomUUID } from "~/lib/utils";
export function CommandMenu() {
const [search, setSearch] = useState("");
@@ -30,7 +31,7 @@ export function CommandMenu() {
// Instant document creation - navigate immediately with a new UUID
const handleCreateDocument = useCallback(() => {
- const newId = crypto.randomUUID();
+ const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
closeAll();
diff --git a/src/app/_components/document-breadcrumb.tsx b/src/app/_components/document-breadcrumb.tsx
index 9017b14..3953b8c 100644
--- a/src/app/_components/document-breadcrumb.tsx
+++ b/src/app/_components/document-breadcrumb.tsx
@@ -15,7 +15,11 @@ import {
PopoverTrigger,
} from "~/app/_components/popover";
import { Input } from "~/app/_components/input";
-import { MobileFormDrawer } from "~/app/_components/mobile-drawer";
+import {
+ focusMobileDrawerInput,
+ MobileFormDrawer,
+ runWithMobileDrawerOpenSync,
+} from "~/app/_components/mobile-drawer";
import { useIsMobile } from "~/hooks/use-mobile";
export function DocumentBreadcrumb() {
@@ -156,7 +160,12 @@ export function DocumentBreadcrumb() {
const openTitleEditor = React.useCallback(() => {
setEditingName(document?.document?.name ?? "Untitled");
if (isMobile) {
- setDrawerOpen(true);
+ runWithMobileDrawerOpenSync(() => {
+ setDrawerOpen(true);
+ });
+ // Controlled open can't recover the gesture after the fact — focus here
+ // (FieldView layout-effect also runs during the sync mount).
+ focusMobileDrawerInput(titleInputRef.current);
} else {
setPopoverOpen(true);
}
diff --git a/src/app/_components/mobile-drawer/index.ts b/src/app/_components/mobile-drawer/index.ts
index 84b11aa..2026303 100644
--- a/src/app/_components/mobile-drawer/index.ts
+++ b/src/app/_components/mobile-drawer/index.ts
@@ -26,5 +26,6 @@ export {
applyMobileDrawerKeyboardInset,
focusMobileDrawerInput,
resetMobileDrawerKeyboardStyles,
+ runWithMobileDrawerOpenSync,
waitForMobileDrawerKeyboardDismiss,
} from "./utils";
diff --git a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx
index 3e7146e..72d9d35 100644
--- a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx
+++ b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx
@@ -1,7 +1,7 @@
"use client";
import {
- useEffect,
+ useLayoutEffect,
useRef,
type FormEvent,
type ReactNode,
@@ -75,20 +75,19 @@ export function MobileDrawerFieldView({
const rootRef = useRef(null);
const leave = useMobileDrawerLeave();
- useEffect(() => {
+ // useLayoutEffect (not useEffect/rAF): iOS Safari only opens the keyboard when
+ // focus runs in the same turn as the user gesture. Pair with flushSync when
+ // mounting this view (see goToView / runWithMobileDrawerOpenSync).
+ useLayoutEffect(() => {
if (!autoFocus) return;
- const frame = requestAnimationFrame(() => {
- const input = rootRef.current?.querySelector("input, textarea");
- if (
- input instanceof HTMLInputElement ||
- input instanceof HTMLTextAreaElement
- ) {
- focusMobileDrawerInput(input);
- }
- });
-
- return () => cancelAnimationFrame(frame);
+ const input = rootRef.current?.querySelector("input, textarea");
+ if (
+ input instanceof HTMLInputElement ||
+ input instanceof HTMLTextAreaElement
+ ) {
+ focusMobileDrawerInput(input);
+ }
}, [autoFocus]);
const handleBack = () => {
diff --git a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts
index 13f742a..50caf2a 100644
--- a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts
+++ b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts
@@ -7,6 +7,7 @@ import {
useRef,
useState,
} from "react";
+import { flushSync } from "react-dom";
import {
MOBILE_DRAWER_KEYBOARD_CLEARANCE_PX,
@@ -75,10 +76,21 @@ export function useMobileDrawerStage({
const goToView = useCallback(
(next: T, nextDirection: number) => {
- setDirection(nextDirection);
- setView(next);
+ const apply = () => {
+ setDirection(nextDirection);
+ setView(next);
+ };
+
+ // Keyboard fields must mount inside the tap so FieldView's layout-effect
+ // focus can open the iOS software keyboard.
+ if (isKeyboardView(next)) {
+ flushSync(apply);
+ return;
+ }
+
+ apply();
},
- [setView],
+ [isKeyboardView, setView],
);
const measureMainStage = useCallback(() => {
diff --git a/src/app/_components/mobile-drawer/utils.ts b/src/app/_components/mobile-drawer/utils.ts
index 4cbef04..50bb4dd 100644
--- a/src/app/_components/mobile-drawer/utils.ts
+++ b/src/app/_components/mobile-drawer/utils.ts
@@ -1,5 +1,16 @@
+import { flushSync } from "react-dom";
+
import { MOBILE_DRAWER_KEYBOARD_SHELL_EXTRA_PX } from "./constants";
+/**
+ * Run state updates that mount a keyboard field inside the current user
+ * gesture. Required for iOS Safari to open the software keyboard — deferred
+ * focus (useEffect / rAF / animation complete) will not.
+ */
+export function runWithMobileDrawerOpenSync(update: () => void) {
+ flushSync(update);
+}
+
/** Clear Vaul inline styles applied while the keyboard was open. */
export function resetMobileDrawerKeyboardStyles() {
const drawer = document.querySelector("[data-vaul-drawer]");
diff --git a/src/app/_components/welcome-client.tsx b/src/app/_components/welcome-client.tsx
index 3ffce28..016c629 100644
--- a/src/app/_components/welcome-client.tsx
+++ b/src/app/_components/welcome-client.tsx
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { Button } from "./ui/button";
import { useCallback } from "react";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
+import { randomUUID } from "~/lib/utils";
interface WelcomeClientProps {
userName: string;
@@ -14,7 +15,7 @@ export function WelcomeClient({ userName }: WelcomeClientProps) {
// Instant document creation - navigate immediately with a new UUID
const handleGetStarted = useCallback(() => {
- const newId = crypto.randomUUID();
+ const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
}, [router]);
diff --git a/src/app/documents/page.tsx b/src/app/documents/page.tsx
index d76cab5..963a22a 100644
--- a/src/app/documents/page.tsx
+++ b/src/app/documents/page.tsx
@@ -6,13 +6,14 @@ import { useRouter } from "next/navigation";
import { MotionFade } from "~/app/_components/motion-fade";
import { useCallback } from "react";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
+import { randomUUID } from "~/lib/utils";
export default function DocumentsPage() {
const router = useRouter();
// Instant document creation - navigate immediately with a new UUID
const handleCreateDocument = useCallback(() => {
- const newId = crypto.randomUUID();
+ const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
}, [router]);
diff --git a/src/hooks/use-collaborative-doc-crdt.ts b/src/hooks/use-collaborative-doc-crdt.ts
index 2381541..d2a115e 100644
--- a/src/hooks/use-collaborative-doc-crdt.ts
+++ b/src/hooks/use-collaborative-doc-crdt.ts
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { api } from "~/trpc/react";
+import { randomUUID } from "~/lib/utils";
interface UseCollaborativeDocCrdtOptions {
documentId: string;
@@ -22,7 +23,7 @@ interface UseCollaborativeDocCrdtResult {
/* -------------------------------- Helpers -------------------------------- */
function generateClientId(): string {
- return crypto.randomUUID();
+ return randomUUID();
}
function base64ToUint8Array(base64: string): Uint8Array {
diff --git a/src/lib/avatar-schema.ts b/src/lib/avatar-schema.ts
index e01b194..65a03bb 100644
--- a/src/lib/avatar-schema.ts
+++ b/src/lib/avatar-schema.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { randomUUID } from "~/lib/utils";
/**
* Shared by the avatar picker, the upload helper, and the tRPC user router.
@@ -55,7 +56,7 @@ export function validateAvatarFile(file: File): string | null {
*/
export function buildAvatarPath(userId: string, file: File): string {
const extension = EXTENSION_BY_TYPE[file.type] ?? "png";
- return `${userId}/${crypto.randomUUID()}.${extension}`;
+ return `${userId}/${randomUUID()}.${extension}`;
}
/**
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..96fdf5d 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/**
+ * UUID that works outside secure contexts (e.g. http://LAN-IP on a phone).
+ * `crypto.randomUUID` is HTTPS/localhost-only; `getRandomValues` is not.
+ */
+export function randomUUID(): string {
+ if (typeof globalThis.crypto?.randomUUID === "function") {
+ return globalThis.crypto.randomUUID()
+ }
+
+ const bytes = new Uint8Array(16)
+ globalThis.crypto.getRandomValues(bytes)
+ // RFC 4122 version 4
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
+
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
+}