diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.navigation.spec.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.navigation.spec.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.navigation.spec.tsx
@@ -0,0 +1,196 @@
+import { act, createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MentionTextarea } from "./MentionTextarea";
+
+// Keep the real editable and selection handlers; these UI subtrees are unused.
+vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));
+vi.mock("./MentionChipMenu", () => ({ MentionChipMenu: () => null }));
+vi.mock("./deck/DeckCard", () => ({ DeckPreviewModal: () => null }));
+vi.mock("./promptStore", () => ({
+  useEnterToGenerateStore: (select: (s: { enabled: boolean }) => unknown) =>
+    select({ enabled: false }),
+}));
+
+const PREFIX = "🙂e\u0301";
+const SUFFIX = "👩‍🚀";
+const VALUE = `${PREFIX}@Image1${SUFFIX}`;
+let container: HTMLDivElement;
+let root: ReturnType<typeof createRoot>;
+let editor: HTMLDivElement;
+let chip: HTMLElement;
+let onChange: ReturnType<typeof vi.fn>;
+let onKeyDown: ReturnType<typeof vi.fn>;
+
+beforeEach(() => {
+  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+  container = document.createElement("div");
+  document.body.appendChild(container);
+  root = createRoot(container);
+  onChange = vi.fn();
+  onKeyDown = vi.fn();
+  act(() => root.render(createElement(MentionTextarea, {
+    value: VALUE,
+    mentionItems: [{ label: "@Image1", type: "image" }],
+    colorMap: {},
+    onChange,
+    onKeyDown,
+  })));
+  editor = container.querySelector("[contenteditable=true]")!;
+  chip = editor.querySelector("[data-mention]")!;
+  editor.focus();
+});
+
+afterEach(() => {
+  act(() => root.unmount());
+  window.getSelection()?.removeAllRanges();
+  container.remove();
+  vi.unstubAllGlobals();
+});
+
+describe("atomic mention arrow navigation", () => {
+  it("moves left past a chip instead of being ejected back to its trailing edge", () => {
+    setCaret(chip.nextSibling!, 0);
+    const html = editor.innerHTML;
+    const event = pressArrow("ArrowLeft");
+
+    // jsdom does not implement native arrow movement. Reproduce the browser
+    // landing inside contenteditable=false, then run the real caret-ejection
+    // listener. The editor must leave ordinary arrow movement to the browser.
+    if (!event.defaultPrevented) {
+      setCaret(chip.lastChild!.firstChild!, 0);
+    }
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(selectedTextBeforeCaret()).toBe(PREFIX);
+    expect(event.defaultPrevented).toBe(false);
+    expect(editor.innerHTML).toBe(html);
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it("moves right past a chip without entering its non-editable contents", () => {
+    setCaret(chip.previousSibling!, PREFIX.length);
+    const html = editor.innerHTML;
+    const event = pressArrow("ArrowRight");
+    setCaret(chip.lastChild!.firstChild!, 0);
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);
+    expect(event.defaultPrevented).toBe(false);
+    expect(editor.innerHTML).toBe(html);
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it("uses the origin edge rather than assuming a left-to-right text direction", () => {
+    setCaret(chip.nextSibling!, 0);
+    // In a bidi run, ArrowRight can enter a chip from its DOM trailing edge.
+    const event = pressArrow("ArrowRight");
+    setCaret(chip.lastChild!.firstChild!, 0);
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(selectedTextBeforeCaret()).toBe(PREFIX);
+    expect(event.defaultPrevented).toBe(false);
+  });
+
+  it.each(["selectionchange", "pointerdown", "mousedown", "blur", "keydown"])("clears the arrow origin after %s", (eventType) => {
+    setCaret(chip.nextSibling!, 0);
+    pressArrow("ArrowLeft");
+    if (eventType === "selectionchange") {
+      setCaret(chip.previousSibling!, 0);
+      act(() => document.dispatchEvent(new Event("selectionchange")));
+    } else if (eventType === "blur") {
+      act(() => editor.blur());
+      editor.focus();
+    } else if (eventType === "keydown") {
+      pressArrow("a");
+    } else {
+      act(() => editor.dispatchEvent(new Event(eventType, { bubbles: true })));
+    }
+    setCaret(chip.lastChild!.firstChild!, 0);
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);
+  });
+
+  it.each(["ArrowLeft", "ArrowRight"])("leaves %s within Unicode text to native grapheme navigation", (key) => {
+    setCaret(chip.nextSibling!, SUFFIX.length);
+    const before = window.getSelection()!.getRangeAt(0).cloneRange();
+    const event = pressArrow(key);
+
+    expect(event.defaultPrevented).toBe(false);
+    expect(window.getSelection()!.anchorNode).toBe(before.startContainer);
+    expect(window.getSelection()!.anchorOffset).toBe(before.startOffset);
+    expect(editor.textContent).toBe(VALUE);
+    expect(onKeyDown).toHaveBeenCalledOnce();
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it.each(["shiftKey", "altKey", "ctrlKey", "metaKey", "isComposing"])("preserves native modified movement (%s)", (flag) => {
+    setCaret(chip.nextSibling!, 0);
+    const event = pressArrow("ArrowLeft", { [flag]: true });
+
+    expect(event.defaultPrevented).toBe(false);
+    expect(window.getSelection()!.anchorNode).toBe(chip.nextSibling);
+    expect(window.getSelection()!.anchorOffset).toBe(0);
+  });
+
+  it.each(["compositionstart", "keyCode"])("does not track arrows during IME (%s)", (mode) => {
+    setCaret(chip.nextSibling!, 0);
+    if (mode === "compositionstart") {
+      act(() => editor.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
+    }
+    const event = pressArrow("ArrowLeft", mode === "keyCode" ? { keyCode: 229 } : {});
+    setCaret(chip.lastChild!.firstChild!, 0);
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);
+    expect(event.defaultPrevented).toBe(false);
+  });
+
+  it("preserves non-collapsed selection for native arrow collapse", () => {
+    const range = document.createRange();
+    range.selectNode(chip);
+    const selection = window.getSelection()!;
+    selection.removeAllRanges();
+    selection.addRange(range);
+    const event = pressArrow("ArrowLeft");
+
+    expect(event.defaultPrevented).toBe(false);
+    expect(selection.isCollapsed).toBe(false);
+    expect(selection.toString()).toBe("@Image1");
+  });
+
+  it("keeps the fallback for an unexpected caret inside a chip", () => {
+    setCaret(chip.lastChild!.firstChild!, 0);
+    act(() => document.dispatchEvent(new Event("selectionchange")));
+
+    expect(window.getSelection()!.anchorNode).toBe(editor);
+    expect(window.getSelection()!.anchorOffset).toBe(2);
+    expect(onChange).not.toHaveBeenCalled();
+  });
+});
+
+function setCaret(node: Node, offset: number) {
+  const range = document.createRange();
+  range.setStart(node, offset);
+  range.collapse(true);
+  const selection = window.getSelection()!;
+  selection.removeAllRanges();
+  selection.addRange(range);
+}
+
+function pressArrow(key: string, flags: KeyboardEventInit = {}) {
+  const event = new KeyboardEvent("keydown", {
+    key, bubbles: true, cancelable: true, ...flags,
+  });
+  act(() => editor.dispatchEvent(event));
+  return event;
+}
+
+function selectedTextBeforeCaret() {
+  const selection = window.getSelection()!;
+  const range = document.createRange();
+  range.setStart(editor, 0);
+  range.setEnd(selection.anchorNode!, selection.anchorOffset);
+  return range.toString();
+}
diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx
--- a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx
+++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx
@@ -639,6 +639,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
     const isInternalUpdate = useRef(false);
     const isComposing = useRef(false);
     const pendingCaret = useRef<number | null>(null);
+    const arrowNavigationOrigin = useRef<number | null>(null);
     // One-shot guard: a programmatic edit (chip replace) leaves the caret
     // right after a mention label, which detectMention would read as a fresh
     // "@..." trigger and reopen the dropdown over the already-confirmed chip.
@@ -887,12 +888,16 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
 
     // Last line of defense for the frozen-input bug: browsers can still drop
     // the caret INSIDE a contenteditable=false chip (Firefox arrow keys,
-    // drag-selection collapse, IME) — typing is then silently ignored. Eject
-    // the caret to just after the chip. Guarded by activeElement so a stale
-    // selection can't steal focus back from an open popover (Chrome focuses
-    // a contentEditable on addRange into it).
+    // drag-selection collapse, IME) — typing is then silently ignored. When
+    // an arrow enters a chip, exit on the opposite edge from its origin;
+    // always exiting after it traps leftward navigation at the same chip.
+    // Other caret placements still fall back to after the chip. Guarded by
+    // activeElement so a stale selection can't steal focus back from an
+    // open popover: Chrome focuses a contentEditable on addRange into it.
     useEffect(() => {
       const handleSelectionChange = () => {
+        const origin = arrowNavigationOrigin.current;
+        arrowNavigationOrigin.current = null;
         const el = editorRef.current;
         if (!el || document.activeElement !== el) return;
         const sel = window.getSelection();
@@ -901,7 +906,14 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
         if (!chip) return;
         try {
           const range = document.createRange();
-          range.setStartAfter(chip);
+          if (
+            origin !== null &&
+            origin >= getNodeStartOffset(el, chip) + nodeLength(chip)
+          ) {
+            range.setStartBefore(chip);
+          } else {
+            range.setStartAfter(chip);
+          }
           range.collapse(true);
           sel.removeAllRanges();
           sel.addRange(range);
@@ -1323,6 +1335,32 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
 
     const handleKeyDown = useCallback(
       (e: React.KeyboardEvent<HTMLDivElement>) => {
+        // Preserve native grapheme and bidi navigation: only remember where
+        // a bare horizontal arrow started, for the chip-ejection fallback.
+        arrowNavigationOrigin.current = null;
+        if (
+          (e.key === "ArrowLeft" || e.key === "ArrowRight") &&
+          !e.shiftKey &&
+          !e.altKey &&
+          !e.ctrlKey &&
+          !e.metaKey &&
+          !isComposing.current &&
+          !e.nativeEvent.isComposing &&
+          e.nativeEvent.keyCode !== 229
+        ) {
+          const el = editorRef.current;
+          const sel = window.getSelection();
+          if (
+            el &&
+            sel?.isCollapsed &&
+            sel.anchorNode &&
+            el.contains(sel.anchorNode) &&
+            !chipContaining(el, sel.anchorNode)
+          ) {
+            arrowNavigationOrigin.current = getCaretOffset(el);
+          }
+        }
+
         if (mentionState.isOpen && filteredItems.length > 0) {
           if (e.key === "ArrowDown") {
             e.preventDefault();
@@ -1442,6 +1480,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
 
     const handleMouseDown = useCallback(
       (e: React.MouseEvent<HTMLDivElement>) => {
+        arrowNavigationOrigin.current = null;
         // Clicking a chip must not move the caret into/around it.
         const target = e.target as HTMLElement;
         if (target.closest?.("[data-mention]")) {
@@ -1590,6 +1629,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
           onCompositionStart={handleCompositionStart}
           onCompositionEnd={handleCompositionEnd}
           onKeyDown={handleKeyDown}
+          onPointerDown={() => {
+            arrowNavigationOrigin.current = null;
+          }}
           onMouseDown={handleMouseDown}
           onClick={handleClick}
           onPaste={handlePaste}
@@ -1597,6 +1639,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
           onCut={handleCut}
           onFocus={onFocus}
           onBlur={(e) => {
+            arrowNavigationOrigin.current = null;
             // Mouse clicks on dropdown items preventDefault pointerdown, so
             // they never land here — but touch taps can move focus into the
             // panel (Android focuses the row button) before its click fires.
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,7 @@
+# The `storyteller-docs` Netlify site still builds this repository, but its
+# build command (./script/netlify_build_docs.sh) and the docs themselves now
+# live in storytold/artcraft-services, so every deploy preview here fails.
+# Skip those builds until the Netlify site is repointed or disconnected, then
+# delete this file.
+[build]
+  ignore = "exit 0"
