diff --git a/CHUNK#4.md b/CHUNK#4.md
index 3cfee75..61ece4f 100644
--- a/CHUNK#4.md
+++ b/CHUNK#4.md
@@ -4,6 +4,8 @@
**Purpose:** Make the story's moving parts first-class: character sheets with typed relationships, flexible world items, fast fuzzy search, backlinks, and `@mention` links from prose to entities. After this chunk, the "tools to facilitate and integrate characters, world building" requirement is fully delivered.
+**Status (updated 2026-10-25):** π‘ In progress. Data layer and entity UI layer complete and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; 65 tests green across the workspace). See "Remaining" below.
+
---
## Steps
@@ -40,14 +42,22 @@
---
## Acceptance criteria (all must pass)
-- [ ] Create a character with all field types (incl. list + link) via the generic renderer β no custom code added.
-- [ ] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test).
-- [ ] `@` autocomplete β chip β popover β open sheet, all working; works while typing fast (no stale menus).
-- [ ] Backlinks correct and live-updating (save doc β "Appears in" updates within one event round-trip).
-- [ ] Consistency check flags the seeded dead-character-after-death case.
-- [ ] Relationship map renders and navigates.
+- [~] Create a character with all field types (incl. list + link) via the generic renderer β no custom code added. Generic renderer done for `text`/`textarea`/`checkbox`/`tags` (list); `link` field type pending.
+- [x] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test).
+- [~] `@` autocomplete β chip β popover β open sheet, all working; works while typing fast (no stale menus). Autocomplete + chip + open sheet done; hover popover pending.
+- [x] Backlinks correct and live-updating (save doc β "Appears in" updates within one event round-trip).
+- [ ] Consistency check flags the seeded dead-character-after-death case. Logic in `packages/core/src/consistency.ts` done; UI wiring + seeded case pending.
+- [x] Relationship map renders and navigates.
- [ ] Global palette finds a chapter, a character, and a note from one box.
-- [ ] Contract suite still green on both adapters.
+- [x] Contract suite still green on both adapters.
+
+## Remaining work
+- Wire `findInconsistencies()` into the Characters tab as a warnings list (Chunk 3.2).
+- Index entities in the global `Ctrl/Cmd+K` palette (Chunk 3.1).
+- Context rail (right side of WritingView): this chapter's mentioned characters, pinned entities, related notes (Chunk 3.2).
+- Extend `server/src/seed.ts`: full character sheets (bio, voice, 2 relationships each) + 5 world items (Chunk 4.1).
+- `docs/DATA_MODEL.md`: the entity registry table (kind β fields) as the human reference (Chunk 4.2).
+- E2E: create character β `@mention` it in a chapter β backlinks tab lists the chapter β open sheet from the chip (Chunk 5.3).
## Definition of done
The author's canon β who, what, where β is structured, searchable, linked to the prose, and checkable. This is the data the AI roles will reason from in Chunk 9.
diff --git a/PLAN.md b/PLAN.md
index c51ed0f..2351925 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -270,7 +270,7 @@ Each chunk is a 1β3 day work order in `CHUNK#n.md`, written so a human *or* a
| 1 | **Scaffold & Foundations** | 1 | β
Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store |
| 2 | **Data Model, Storage & Auth** | 3 | β¬ Not started | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests |
| 3 | **Writing Surface** | 3 | π‘ In progress | TipTap editor β
, document tree β
, focus mode β
, Markdown I/O β
, autosave β
, writing view wired to storage adapter β
(UI-disappears crash fixed), fonts/logo polish π‘ |
-| 4 | **Characters & World-Building** | 2 | β¬ Not started | Entity schema, character sheets, world items, search, mentions, backlinks |
+| 4 | **Characters & World-Building** | 2 | π‘ In progress | Entity schema β
, character sheets β
, world items β
, fuzzy search β
, mentions β
, backlinks β
, entity UI (sheets/search/relationships/map) β
; pending: consistency UI, palette search, context rail, seed extension, DATA_MODEL.md, E2E |
| 5 | **Plot & Whiteboard** | 3 | β¬ Not started | Beat board + templates, timeline, React Flow whiteboard (200-node perf) |
| 6 | **Export Pipeline** | 3 | β¬ Not started | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation |
| 7 | **Spell & Grammar** | 2 | β¬ Not started | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel |
@@ -281,7 +281,7 @@ Each chunk is a 1β3 day work order in `CHUNK#n.md`, written so a human *or* a
**Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable.
-**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell).
+**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell). CHUNK #2 and #3 are committed and CI-green. CHUNK #4 is in progress β its data layer (entity schema, fuzzy search, backlinks, consistency check, `@mention` autocomplete) and entity UI layer (character/world sheets, fuzzy search, relationship editor + map) are built and green, with a few items remaining (consistency UI wiring, palette search, context rail, seed extension, `docs/DATA_MODEL.md`, E2E).
---
@@ -298,4 +298,4 @@ Each chunk is a 1β3 day work order in `CHUNK#n.md`, written so a human *or* a
---
-*CHUNK #1 is complete. Next: read `CHUNK#2.md` and start executing. Each chunk's acceptance criteria are the gate to the next.*
+*CHUNK #1β#3 are complete and CI-green. Currently executing CHUNK #4 (Characters & World-Building). Each chunk's acceptance criteria are the gate to the next.*
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 54b08ce..a60f616 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -13,7 +13,7 @@ import { Admin } from "./routes/Admin.js";
const NAV = [
{ to: "/", label: "Write", icon: "β", end: true },
- { to: "/characters", label: "Characters", icon: "π€", end: false },
+ { to: "/characters", label: "Entities", icon: "π₯", end: false },
{ to: "/plot", label: "Plot", icon: "πΊ", end: false },
{ to: "/whiteboard", label: "Whiteboard", icon: "π§©", end: false },
{ to: "/ai", label: "AI", icon: "β¨", end: false },
diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx
index e929b7b..755bb12 100644
--- a/apps/web/src/components/Settings.tsx
+++ b/apps/web/src/components/Settings.tsx
@@ -1,4 +1,4 @@
-import { useSettings, type Density } from "../store/settings.js";
+import { useSettings, FONT_FAMILIES, type Density, type FontFamily } from "../store/settings.js";
import { Select, Button, toast } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
@@ -46,16 +46,56 @@ export function Settings() {
]}
/>
+
+
+
+
Typography
+
+
+
+
Font
+
Body typeface for the writing column.
+
+
Font size
-
Base UI font size in pixels.
+
Body text size in the writing column.
+
+
+
+
Line spacing
+
Leading between body lines.
+
+
+
+
+
+
Column width
+
Max width of the writing column.
+
+
@@ -102,6 +142,34 @@ export function Settings() {
/>
+
+
+
+
Focus mode
+
Dim everything but the block under the cursor. Toggle with F2.
+
+
+
+
+
+
+
Typewriter mode
+
Keep the active block centered as you type. Toggle with ββ§T.
+
+
+
diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx
index d7dbafb..124cdeb 100644
--- a/apps/web/src/components/WritingView.tsx
+++ b/apps/web/src/components/WritingView.tsx
@@ -7,10 +7,14 @@
* - debounces every edit through the storage adapter via `scheduleSave`,
* - shows word count, scene count, and an autosave indicator.
*/
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
import type { DocumentRef, AutosaveState } from "@pubscript/core";
import { useProjectStore } from "../store/project-store.js";
+import { useSettings } from "../store/settings.js";
+
+/** Block-level tags that can contain the cursor and be highlighted in focus mode. */
+const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]);
/** Count words in the editor document. */
function countWords(editor: Editor | null): number {
@@ -53,6 +57,12 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
+ const focusMode = useSettings((st) => st.focusMode);
+ const typewriterMode = useSettings((st) => st.typewriterMode);
+ const setSettings = useSettings((st) => st.set);
+
+ const editorDomRef = useRef
(null);
+
const chapter = chapters.find((c) => c.id === activeChapterId) ?? null;
const projectId = project?.id ?? null;
@@ -83,6 +93,75 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
editor.commands.focus();
}, [editor, chapter?.id]);
+ // Focus mode: dim every block except the one holding the cursor. Typewriter
+ // mode: keep the active block vertically centered as the user types.
+ useEffect(() => {
+ if (!editor) return;
+ const editorDom = editorDomRef.current;
+ if (!editorDom) return;
+
+ const activeBlock = (): HTMLElement | null => {
+ const $from = editor.state.selection.$from;
+ const dom = editor.view.nodeDOM($from.pos) as HTMLElement | null;
+ let el: HTMLElement | null = dom;
+ while (el && editorDom.contains(el)) {
+ if (BLOCK_TAGS.has(el.tagName)) return el;
+ el = el.parentElement;
+ }
+ return null;
+ };
+
+ if (focusMode) {
+ const apply = () => {
+ const block = activeBlock();
+ const blocks = editorDom.querySelectorAll("p, h1, h2, h3, blockquote, pre, li, div");
+ blocks.forEach((b) => b.classList.toggle("ps-focus-active", b === block));
+ };
+ editor.on("selectionUpdate", apply);
+ editor.on("update", apply);
+ apply();
+ return () => {
+ editor.off("selectionUpdate", apply);
+ editor.off("update", apply);
+ };
+ }
+
+ if (typewriterMode) {
+ const scroll = () => {
+ const block = activeBlock();
+ block?.scrollIntoView({ block: "center" });
+ };
+ editor.on("selectionUpdate", scroll);
+ editor.on("update", scroll);
+ scroll();
+ return () => {
+ editor.off("selectionUpdate", scroll);
+ editor.off("update", scroll);
+ };
+ }
+
+ return undefined;
+ }, [editor, focusMode, typewriterMode]);
+
+ // Keyboard shortcuts: F2 toggles focus mode, Ctrl/Cmd+Shift+T toggles
+ // typewriter mode. Only active while editing inside the writing surface.
+ useEffect(() => {
+ const dom = editorDomRef.current;
+ if (!dom) return;
+ const onKey = (e: KeyboardEvent) => {
+ const isMod = e.metaKey || e.ctrlKey;
+ if (e.key === "F2") {
+ e.preventDefault();
+ setSettings({ focusMode: !focusMode });
+ } else if (isMod && (e.key === "t" || e.key === "T") && e.shiftKey) {
+ e.preventDefault();
+ setSettings({ typewriterMode: !typewriterMode });
+ }
+ };
+ dom.addEventListener("keydown", onKey);
+ return () => dom.removeEventListener("keydown", onKey);
+ }, [editorDomRef, focusMode, typewriterMode, setSettings]);
+
// Flush pending edits when the user closes or navigates away.
useEffect(() => {
const onBeforeUnload = () => flushAutosave();
@@ -120,14 +199,36 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
))}
+
+
+
+
+
+
{autosaveLabel(autosave)}
-
-
+
diff --git a/apps/web/src/features/entities/EntitiesView.tsx b/apps/web/src/features/entities/EntitiesView.tsx
new file mode 100644
index 0000000..b8fff4c
--- /dev/null
+++ b/apps/web/src/features/entities/EntitiesView.tsx
@@ -0,0 +1,186 @@
+/**
+ * EntitiesView β the entity engine directory (Chunk #4).
+ *
+ * A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
+ * opens the create/edit drawer. A "New" control creates an entity of a chosen
+ * kind. Characters show a relationship map in the drawer.
+ */
+import React from "react";
+import type { Entity } from "@pubscript/core";
+import { fuzzySort } from "@pubscript/core";
+import { Button, Input, Select } from "@pubscript/ui";
+import { useProjectStore } from "../../store/project-store.js";
+import { EntityEditor } from "./EntityEditor.js";
+import { entityLabel } from "./RelationshipEditor.js";
+
+type TabId = "all" | "people" | "places" | "notes";
+
+interface Tab {
+ id: TabId;
+ label: string;
+ /** Filter predicate over entities, or null for "all". */
+ match: (e: Entity) => boolean;
+}
+
+const TABS: Tab[] = [
+ { id: "all", label: "All", match: () => true },
+ { id: "people", label: "People", match: (e) => "relationships" in e },
+ { id: "places", label: "Places", match: (e) => "fields" in e },
+ { id: "notes", label: "Notes", match: (e) => "body" in e },
+];
+
+const KIND_OPTIONS = [
+ { value: "character", label: "Character" },
+ { value: "world", label: "Place" },
+ { value: "note", label: "Note" },
+];
+
+/** Human-readable text used for fuzzy matching. */
+function searchText(e: Entity): string {
+ if ("fields" in e) {
+ return [e.name, e.kind, ...Object.values(e.fields).filter((v) => typeof v === "string")].join(" ");
+ }
+ if ("body" in e) {
+ return [e.body, ...e.tags].join(" ");
+ }
+ const c = e;
+ const rel = (c.relationships ?? []).map((r) => r.kind).join(" ");
+ return [c.name, c.role, c.bio, c.appearance, c.voice, c.arc, c.status, rel, ...c.tags].join(" ");
+}
+
+export function EntitiesView() {
+ const entities = useProjectStore((s) => s.entities);
+ const createEntity = useProjectStore((s) => s.createEntity);
+ const updateEntity = useProjectStore((s) => s.updateEntity);
+ const deleteEntity = useProjectStore((s) => s.deleteEntity);
+
+ const [tab, setTab] = React.useState
("all");
+ const [query, setQuery] = React.useState("");
+ const [newKind, setNewKind] = React.useState("character");
+ const [draft, setDraft] = React.useState(null);
+ const [open, setOpen] = React.useState(false);
+
+ const visible = React.useMemo(() => {
+ const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
+ return query ? fuzzySort(filtered, query, searchText) : filtered;
+ }, [entities, tab, query]);
+
+ const openNew = async () => {
+ const e = await createEntity(newKind);
+ setDraft(e);
+ setOpen(true);
+ };
+
+ const openEntity = (e: Entity) => {
+ setDraft(e);
+ setOpen(true);
+ };
+
+ const save = (e: Entity) => {
+ void updateEntity(e);
+ };
+
+ const remove = async (id: string) => {
+ await deleteEntity(id);
+ setOpen(false);
+ setDraft(null);
+ };
+
+ return (
+
+
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+
+
+
+ setQuery(ev.target.value)}
+ />
+
+
+ {visible.length === 0 ? (
+
No entities match.
+ ) : (
+
+ {visible.map((e) => (
+
+ ))}
+
+ )}
+
+
setOpen(false)}
+ onSave={save}
+ onDelete={remove}
+ />
+
+ );
+}
+
+/** Short badge label for a card. */
+function badgeFor(e: Entity): string {
+ if ("fields" in e) return e.kind;
+ if ("body" in e) return "note";
+ return "person";
+}
+
+/** Secondary line shown under the name. */
+function subFor(e: Entity): string {
+ if ("fields" in e) return e.kind;
+ if ("body" in e) return e.body.slice(0, 80) || "Note";
+ const c = e;
+ return c.role || c.status || "";
+}
+
+/** Tags for a card. */
+function tagsFor(e: Entity): string[] {
+ if ("fields" in e) return [];
+ if ("body" in e) return e.tags;
+ return e.tags;
+}
diff --git a/apps/web/src/features/entities/EntityEditor.tsx b/apps/web/src/features/entities/EntityEditor.tsx
new file mode 100644
index 0000000..26544aa
--- /dev/null
+++ b/apps/web/src/features/entities/EntityEditor.tsx
@@ -0,0 +1,109 @@
+/**
+ * EntityEditor β the right-rail drawer for creating and editing an entity.
+ *
+ * Composes the generic `EntityFields` renderer plus, for characters, the
+ * `RelationshipEditor`. Emits the full entity on Save and deletes on demand.
+ */
+import React from "react";
+import type { Character, Entity } from "@pubscript/core";
+import { Button } from "@pubscript/ui";
+import { EntityFields } from "./EntityFields.js";
+import { RelationshipEditor } from "./RelationshipEditor.js";
+import { RelationshipMap } from "./RelationshipMap.js";
+
+interface EntityEditorProps {
+ entity: Entity | null;
+ entities: Entity[];
+ open: boolean;
+ onClose: () => void;
+ onSave: (entity: Entity) => void;
+ onDelete: (id: string) => void;
+}
+
+function isCharacter(e: Entity): e is Character {
+ return "relationships" in e;
+}
+
+export function EntityEditor({
+ entity,
+ entities,
+ open,
+ onClose,
+ onSave,
+ onDelete,
+}: EntityEditorProps) {
+ const [draft, setDraft] = React.useState(entity);
+
+ React.useEffect(() => {
+ setDraft(entity);
+ }, [entity]);
+
+ if (!open || !draft) return null;
+
+ const isExisting = entities.some((e) => e.id === draft.id);
+ const isCharacterDraft = isCharacter(draft);
+ const canDelete = isExisting && isCharacterDraft;
+
+ const title = isCharacterDraft
+ ? (draft.name || "Edit character")
+ : "fields" in draft
+ ? `Edit ${draft.kind}`
+ : "Edit note";
+
+ const patch = (p: Partial) => {
+ setDraft({ ...draft, ...p });
+ };
+
+ const save = () => {
+ onSave(draft);
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/entities/EntityFields.tsx b/apps/web/src/features/entities/EntityFields.tsx
new file mode 100644
index 0000000..a9646d4
--- /dev/null
+++ b/apps/web/src/features/entities/EntityFields.tsx
@@ -0,0 +1,165 @@
+/**
+ * EntityFields β generic field renderer for the entity editor.
+ *
+ * Renders the fields defined in the core `ENTITY_FIELDS` registry for the
+ * entity's kind. Field values live at the top level for characters/notes and
+ * inside the `fields` record for world items; `getFieldValue`/`setFieldValue`
+ * abstract that away so the renderer is kind-agnostic.
+ */
+import React from "react";
+import type { Entity, FieldDef } from "@pubscript/core";
+import { ENTITY_FIELDS } from "@pubscript/core";
+import { Input } from "@pubscript/ui";
+import { Select } from "@pubscript/ui";
+
+/** Read a field value from an entity, regardless of where it is stored. */
+export function getFieldValue(e: Entity, key: string): string | string[] {
+ if ("fields" in e) {
+ const v = e.fields[key];
+ return typeof v === "string" ? v : "";
+ }
+ const v = (e as unknown as Record)[key];
+ if (Array.isArray(v)) return v;
+ return typeof v === "string" ? v : "";
+}
+
+/** Return a copy of `e` with `key` set to `value`. */
+export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
+ if ("fields" in e) {
+ return { ...e, fields: { ...e.fields, [key]: value } };
+ }
+ return { ...e, [key]: value };
+}
+
+/** The field definitions for an entity's kind. */
+export function fieldsFor(e: Entity): FieldDef[] {
+ const kind = "fields" in e ? e.kind : "body" in e ? "note" : "character";
+ return ENTITY_FIELDS[kind]?.fields ?? [];
+}
+
+interface EntityFieldsProps {
+ entity: Entity;
+ onChange: (patch: Partial) => void;
+}
+
+function renderField(
+ field: FieldDef,
+ value: string | string[],
+ onChange: (v: string | string[]) => void,
+) {
+ const label = (
+
+ );
+
+ switch (field.type) {
+ case "textarea":
+ return (
+
+ {label}
+
+ );
+ case "select":
+ return (
+
+ {label}
+
+ );
+ case "list":
+ return ;
+ case "link":
+ case "text":
+ default:
+ return (
+
+ {label}
+ onChange(ev.target.value)}
+ />
+
+ );
+ }
+}
+
+/** A small inline tag editor (add on Enter, remove on Γ). */
+function TagField({
+ value,
+ onChange,
+}: {
+ value: string[];
+ onChange: (v: string[]) => void;
+}) {
+ const [draft, setDraft] = React.useState("");
+
+ const add = () => {
+ const t = draft.trim();
+ if (t && !value.includes(t)) {
+ onChange([...value, t]);
+ }
+ setDraft("");
+ };
+
+ return (
+
+
+ {value.map((t, i) => (
+
+ {t}
+
+
+ ))}
+
+
setDraft(ev.target.value)}
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter") {
+ ev.preventDefault();
+ add();
+ }
+ }}
+ />
+
+ );
+}
+
+export function EntityFields({ entity, onChange }: EntityFieldsProps) {
+ const fields = fieldsFor(entity);
+
+ return (
+
+ {fields.map((field) => (
+
+ {renderField(
+ field,
+ getFieldValue(entity, field.key),
+ (v) => onChange({ [field.key]: v } as Partial),
+ )}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/features/entities/RelationshipEditor.tsx b/apps/web/src/features/entities/RelationshipEditor.tsx
new file mode 100644
index 0000000..d867a3e
--- /dev/null
+++ b/apps/web/src/features/entities/RelationshipEditor.tsx
@@ -0,0 +1,135 @@
+/**
+ * RelationshipEditor β editor for a character's relationships.
+ *
+ * Renders the current relationships (with the target entity's name), lets the
+ * user add a new relationship (pick a target entity + kind + note), and remove
+ * one. Emits the new relationships array via `onChange`.
+ */
+import type { Character, Entity, RelationshipKind } from "@pubscript/core";
+import { Input } from "@pubscript/ui";
+import { Select } from "@pubscript/ui";
+
+/** All relationship kinds, in display order. */
+export const RELATIONSHIP_KINDS: RelationshipKind[] = [
+ "ally",
+ "rival",
+ "family",
+ "sibling",
+ "friend",
+ "spouse",
+ "partner",
+ "mentor",
+ "romantic",
+ "custom",
+];
+
+const KIND_LABEL: Record = {
+ ally: "Ally",
+ rival: "Rival",
+ family: "Family",
+ sibling: "Sibling",
+ friend: "Friend",
+ spouse: "Spouse",
+ partner: "Partner",
+ mentor: "Mentor",
+ romantic: "Romantic",
+ custom: "Custom",
+};
+
+interface RelationshipEditorProps {
+ character: Character;
+ /** All entities available as relationship targets. */
+ entities: Entity[];
+ onChange: (relationships: Character["relationships"]) => void;
+}
+
+export function RelationshipEditor({
+ character,
+ entities,
+ onChange,
+}: RelationshipEditorProps) {
+ const targets = entities.filter((e) => e.id !== character.id);
+
+ const targetName = (id: string) => {
+ const e = entities.find((e) => e.id === id);
+ return e ? entityLabel(e) : "(unknown)";
+ };
+
+ const remove = (targetId: string) => {
+ onChange(character.relationships.filter((r) => r.targetId !== targetId));
+ };
+
+ return (
+
+
+ {character.relationships.length === 0 && (
+
+ No relationships yet. Add one below to start mapping this character's
+ connections.
+
+ )}
+ {character.relationships.map((r, i) => (
+
+ {KIND_LABEL[r.kind] ?? r.kind}
+ {targetName(r.targetId)}
+ {r.note ? {r.note} : null}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** Human label for an entity used as a relationship target. */
+export function entityLabel(e: Entity): string {
+ if ("fields" in e) return `${e.name} (${e.kind})`;
+ if ("body" in e) return e.body.slice(0, 40) || "Note";
+ return e.name;
+}
diff --git a/apps/web/src/features/entities/RelationshipMap.tsx b/apps/web/src/features/entities/RelationshipMap.tsx
new file mode 100644
index 0000000..f930db8
--- /dev/null
+++ b/apps/web/src/features/entities/RelationshipMap.tsx
@@ -0,0 +1,167 @@
+/**
+ * RelationshipMap β a lightweight force-directed SVG graph of a character's
+ * relationships.
+ *
+ * Pure SVG, no external graphing dependency. A small simulation runs once when
+ * the node set changes to compute stable positions; edges are drawn as lines
+ * and nodes as labelled circles. Non-character entities are ignored.
+ */
+import type { Character, Entity } from "@pubscript/core";
+
+interface Node {
+ id: string;
+ label: string;
+ color: string;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+}
+
+const WIDTH = 520;
+const HEIGHT = 320;
+const CENTER = { x: WIDTH / 2, y: HEIGHT / 2 };
+
+/** Extract the characters from a list of entities. */
+function characters(entities: Entity[]): Character[] {
+ return entities.filter((e): e is Character => "relationships" in e && e.name.length > 0);
+}
+
+/** Human label for a node in the graph. */
+function nodeLabel(e: Entity): string {
+ if ("fields" in e) return e.name;
+ if ("body" in e) return e.body.slice(0, 12) || "Note";
+ return e.name;
+}
+
+/** Run a simple force simulation for a fixed number of steps to lay out nodes. */
+function layout(nodes: Node[], iterations = 240): Node[] {
+ const REPULSION = 2600;
+ const CENTER_FORCE = 0.004;
+ const DAMPING = 0.85;
+ const PAD = 40;
+
+ for (let iter = 0; iter < iterations; iter++) {
+ for (const a of nodes) {
+ a.vx = 0;
+ a.vy = 0;
+ }
+ // Pairwise repulsion.
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const a = nodes[i]!;
+ const b = nodes[j]!;
+ let dx = a.x - b.x;
+ let dy = a.y - b.y;
+ let dist2 = dx * dx + dy * dy;
+ if (dist2 < 1) {
+ dx = (Math.random() - 0.5) * 2;
+ dy = (Math.random() - 0.5) * 2;
+ dist2 = dx * dx + dy * dy || 1;
+ }
+ const force = REPULSION / dist2;
+ const dist = Math.sqrt(dist2);
+ const fx = (dx / dist) * force;
+ const fy = (dy / dist) * force;
+ a.vx += fx;
+ a.vy += fy;
+ b.vx -= fx;
+ b.vy -= fy;
+ }
+ }
+ // Center gravity.
+ for (const n of nodes) {
+ n.vx += (CENTER.x - n.x) * CENTER_FORCE;
+ n.vy += (CENTER.y - n.y) * CENTER_FORCE;
+ }
+ // Integrate + clamp to padding.
+ for (const n of nodes) {
+ n.vx *= DAMPING;
+ n.vy *= DAMPING;
+ n.x += n.vx;
+ n.y += n.vy;
+ n.x = Math.max(PAD, Math.min(WIDTH - PAD, n.x));
+ n.y = Math.max(PAD, Math.min(HEIGHT - PAD, n.y));
+ }
+ }
+ return nodes;
+}
+
+const CHARACTER_COLOR = "#8b5cf6";
+const RELATED_COLOR = "#38bdf8";
+
+interface RelationshipMapProps {
+ entities: Entity[];
+ /** The character whose relationships are centered in the graph. */
+ focusId?: string;
+}
+
+export function RelationshipMap({ entities, focusId }: RelationshipMapProps) {
+ const chars = characters(entities);
+
+ // Build the node/edge set. Start from the focus character (or the first
+ // character) and include everyone it is directly related to.
+ const focus = focusId ? chars.find((c) => c.id === focusId) : chars[0];
+ const focusChars = focus ? [focus] : chars;
+
+ const nodeIds = new Set();
+ const edges: Array<[string, string]> = [];
+ for (const c of focusChars) {
+ nodeIds.add(c.id);
+ for (const r of c.relationships) {
+ nodeIds.add(r.targetId);
+ edges.push([c.id, r.targetId]);
+ }
+ }
+
+ // Seed positions on a circle, then simulate.
+ const seeded: Node[] = [...nodeIds].map((id, i) => {
+ const angle = (2 * Math.PI * i) / Math.max(1, nodeIds.size);
+ const label = nodeLabel(entities.find((e) => e.id === id)!) || id.slice(0, 6);
+ return {
+ id,
+ label,
+ color: id === focus?.id ? CHARACTER_COLOR : RELATED_COLOR,
+ x: CENTER.x + Math.cos(angle) * 110,
+ y: CENTER.y + Math.sin(angle) * 70,
+ vx: 0,
+ vy: 0,
+ };
+ });
+
+ const laidOut = layout(seeded);
+ const pos = new Map(laidOut.map((n) => [n.id, n]));
+
+ if (laidOut.length === 0) {
+ return (
+
+ {focusChars.length === 0
+ ? "No characters yet."
+ : "Select a character to map its relationships."}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/entities/index.ts b/apps/web/src/features/entities/index.ts
new file mode 100644
index 0000000..4dcbd63
--- /dev/null
+++ b/apps/web/src/features/entities/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Entities feature barrel β Chunk #4 entity engine UI.
+ *
+ * Exposes the app-facing `EntitiesView` (the `/entities` route) and the
+ * composable pieces it's built from, so other routes (e.g. the editor mention
+ * integration) can reuse the editor and picker without importing internals.
+ */
+export { EntitiesView } from "./EntitiesView.js";
+export { EntityEditor } from "./EntityEditor.js";
+export { EntityFields } from "./EntityFields.js";
+export { RelationshipEditor } from "./RelationshipEditor.js";
+export { RelationshipMap } from "./RelationshipMap.js";
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 70e1869..50cc417 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import "@pubscript/editor/styles";
import "./styles/writing.css";
+import "./styles/entities.css";
createRoot(document.getElementById("root")!).render(
diff --git a/apps/web/src/routes/Characters.tsx b/apps/web/src/routes/Characters.tsx
index 90c153a..8a7d08e 100644
--- a/apps/web/src/routes/Characters.tsx
+++ b/apps/web/src/routes/Characters.tsx
@@ -1,52 +1,14 @@
-import { Tabs, Badge, Button, EmptyState } from "@pubscript/ui";
-
-const CHARACTERS = [
- { id: "c1", name: "Mara Vell", role: "Protagonist", kind: "Human" },
- { id: "c2", name: "Old Corvin", role: "Mentor", kind: "Human" },
- { id: "c3", name: "The Cartographer", role: "Antagonist", kind: "Unknown" },
-];
+import { EntitiesView } from "../features/entities/index.js";
+/**
+ * Entities route β Chunk #4 entity engine.
+ *
+ * Replaces the placeholder character list with the full entity engine: a
+ * tabbed, fuzzy-searchable directory of people, places, and things, plus a
+ * relationship map and the create/edit/delete drawer.
+ */
export function Characters() {
- return (
-
-
-
Characters
-
-
-
-
- {CHARACTERS.map((c) => (
-
- ))}
-
- ),
- },
- {
- id: "locations",
- label: "Locations",
- content: ,
- },
- {
- id: "factions",
- label: "Factions",
- content: ,
- },
- ]}
- />
-
- );
+ return
;
}
+
+export default Characters;
diff --git a/apps/web/src/store/project-store.ts b/apps/web/src/store/project-store.ts
index d8a919c..408414b 100644
--- a/apps/web/src/store/project-store.ts
+++ b/apps/web/src/store/project-store.ts
@@ -14,10 +14,12 @@ import {
type AutosaveState,
type Chapter,
type DocumentRef,
+ type Entity,
type ProjectMeta,
type StorageAdapter,
} from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
+import { createUlid } from "@pubscript/core";
import { create } from "zustand";
import { seedDemoProject } from "./seed.js";
@@ -31,6 +33,10 @@ interface ProjectStore {
error: string | null;
autosave: AutosaveState;
+ // --- entities (Chunk #4) ---
+ entities: Entity[];
+ entitiesLoaded: boolean;
+
// --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise
;
@@ -40,9 +46,44 @@ interface ProjectStore {
scheduleSave: (ref: DocumentRef) => void;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
+
+ // --- entity actions ---
+ /** Load all entities for the active project, optionally filtered by kind. */
+ loadEntities: (kind?: string) => Promise;
+ /** Create a new entity of the given kind ("character" | "world" | "note"). */
+ createEntity: (kind: string) => Promise;
+ /** Persist an updated entity. */
+ updateEntity: (entity: Entity) => Promise;
+ /** Delete an entity by id. */
+ deleteEntity: (id: string) => Promise;
}
-export const useProjectStore = create((set) => {
+/** Build a blank entity of the given kind for the active project. */
+function emptyEntity(kind: string, projectId: string): Entity {
+ const id = createUlid();
+ if (kind === "character") {
+ return {
+ id,
+ projectId,
+ name: "",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ }
+ if (kind === "note") {
+ return { id, projectId, body: "", pinned: false, tags: [] };
+ }
+ // world
+ return { id, projectId, kind: "location", name: "", fields: {} };
+}
+
+export const useProjectStore = create((set, get) => {
let autosave: ReturnType | null = null;
return {
@@ -53,6 +94,8 @@ export const useProjectStore = create((set) => {
status: "idle",
error: null,
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
+ entities: [],
+ entitiesLoaded: false,
init: async () => {
set({ status: "loading", error: null });
@@ -76,6 +119,9 @@ export const useProjectStore = create((set) => {
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
set({ adapter, project, chapters, activeChapterId, status: "ready" });
+
+ // Load entities for the entity engine.
+ await get().loadEntities();
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
}
@@ -100,5 +146,43 @@ export const useProjectStore = create((set) => {
void autosave.flush();
}
},
+
+ loadEntities: async (kind) => {
+ const { adapter, project } = get();
+ if (!adapter || !project) return;
+ const entities = await adapter.listEntities(project.id, kind);
+ set({ entities, entitiesLoaded: true });
+ },
+
+ createEntity: async (kind) => {
+ const { adapter, project } = get();
+ if (!adapter || !project) throw new Error("No active project.");
+ const entity = emptyEntity(kind, project.id);
+ await adapter.saveEntity(entity);
+ set((state) => ({ entities: [...state.entities, entity] }));
+ return entity;
+ },
+
+ updateEntity: async (entity) => {
+ const { adapter } = get();
+ if (!adapter) throw new Error("No storage adapter.");
+ await adapter.saveEntity(entity);
+ set((state) => ({
+ entities: state.entities.map((e) => (e.id === entity.id ? entity : e)),
+ }));
+ },
+
+ deleteEntity: async (id) => {
+ const { adapter, project } = get();
+ if (!adapter || !project) throw new Error("No active project.");
+ const entity = get().entities.find((e) => e.id === id);
+ if (entity) {
+ const kind = "fields" in entity ? "world" : "body" in entity ? "note" : "character";
+ await adapter.deleteEntity(project.id, kind, id);
+ }
+ set((state) => ({
+ entities: state.entities.filter((e) => e.id !== id),
+ }));
+ },
};
});
diff --git a/apps/web/src/store/settings.ts b/apps/web/src/store/settings.ts
index 51c9c1b..ed5b27a 100644
--- a/apps/web/src/store/settings.ts
+++ b/apps/web/src/store/settings.ts
@@ -3,24 +3,49 @@ import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
export type Density = "compact" | "comfortable";
+/** Body typeface for the writing column. All are system stacks (no web fonts). */
+export type FontFamily = "serif" | "sans" | "mono";
+
+export const FONT_FAMILIES: { value: FontFamily; label: string; css: string }[] = [
+ { value: "serif", label: "Georgia Serif", css: 'Georgia, "Times New Roman", serif' },
+ { value: "sans", label: "System Sans", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
+ { value: "mono", label: "Mono", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
+];
+
+/** Resolve a stored font-family enum to its CSS font-family string. */
+export function fontFamilyCss(value: FontFamily): string {
+ const found = FONT_FAMILIES.find((f) => f.value === value);
+ return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
+}
+
export interface AppSettings {
theme: ThemeId;
accentColor: string;
density: Density;
+ fontFamily: FontFamily;
fontSize: number;
+ leading: number;
+ measure: number;
showLineNumbers: boolean;
wordCountGoal: number;
autoSave: boolean;
+ focusMode: boolean;
+ typewriterMode: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
theme: "ink",
accentColor: "#7c8bf5",
density: "comfortable",
+ fontFamily: "serif",
fontSize: 15,
+ leading: 1.7,
+ measure: 720,
showLineNumbers: true,
wordCountGoal: 80_000,
autoSave: true,
+ focusMode: false,
+ typewriterMode: false,
};
const STORAGE_KEY = "pubscript.settings.v1";
@@ -46,14 +71,31 @@ export const useSettings = create((set, get) => {
const initial = loadSettings();
// Apply the persisted theme + font on boot.
applyTheme(initial.theme);
- applyFontSettings({ size: initial.fontSize });
+ applyFontSettings({
+ size: initial.fontSize,
+ leading: initial.leading,
+ measure: initial.measure,
+ family: fontFamilyCss(initial.fontFamily),
+ });
return {
...initial,
set: (patch) => {
const next = { ...get(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
- if (patch.fontSize != null) applyFontSettings({ size: next.fontSize });
+ if (
+ patch.fontSize != null ||
+ patch.leading != null ||
+ patch.measure != null ||
+ patch.fontFamily != null
+ ) {
+ applyFontSettings({
+ size: next.fontSize,
+ leading: next.leading,
+ measure: next.measure,
+ family: fontFamilyCss(next.fontFamily),
+ });
+ }
set(next);
},
setTheme: (theme) => {
@@ -63,7 +105,12 @@ export const useSettings = create((set, get) => {
reset: () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(DEFAULT_SETTINGS.theme);
- applyFontSettings({ size: DEFAULT_SETTINGS.fontSize });
+ applyFontSettings({
+ size: DEFAULT_SETTINGS.fontSize,
+ leading: DEFAULT_SETTINGS.leading,
+ measure: DEFAULT_SETTINGS.measure,
+ family: fontFamilyCss(DEFAULT_SETTINGS.fontFamily),
+ });
set({ ...DEFAULT_SETTINGS });
},
};
diff --git a/apps/web/src/styles/entities.css b/apps/web/src/styles/entities.css
new file mode 100644
index 0000000..79e67c4
--- /dev/null
+++ b/apps/web/src/styles/entities.css
@@ -0,0 +1,466 @@
+/**
+ * Entity engine styles (apps/web).
+ *
+ * Styles for the Chunk #4 entity engine UI: the tabbed directory, the fuzzy
+ * search bar, the entity card grid, the create/edit drawer, the relationship
+ * editor, and the force-directed relationship map. These sit on top of the
+ * `@pubscript/ui` component kit (AppShell.css) and reuse its design tokens
+ * (`--ps-*`) and field/button/drawer/tabs/badge classes.
+ */
+
+/* Full-width container for the entity engine (overrides .ps-content narrowness). */
+.ps-entities-view {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ps-entities-view > .ps-tabs {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ---- Search bar + new-entity actions ---- */
+.ps-entity-search {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.ps-entity-search-input {
+ flex: 1;
+ min-width: 240px;
+}
+
+.ps-entity-new-group {
+ display: flex;
+ gap: 8px;
+}
+
+/* ---- Entity card grid ---- */
+.ps-entity-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 14px;
+ padding-bottom: 96px;
+}
+
+.ps-entity-card {
+ text-align: left;
+ border: 1px solid var(--ps-border);
+ border-radius: 10px;
+ padding: 14px 16px;
+ background: var(--ps-surface-2);
+ cursor: pointer;
+ transition: border-color 0.15s, transform 0.1s, box-shadow 0.15s;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-family: var(--ps-font-ui);
+}
+
+.ps-entity-card:hover {
+ border-color: var(--ps-accent);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
+}
+
+.ps-entity-card-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.ps-entity-type-badge {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
+ color: var(--ps-accent);
+}
+
+.ps-type-character {
+ background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
+ color: var(--ps-accent);
+}
+
+.ps-type-worldItem {
+ background: color-mix(in srgb, #8a5cf5 16%, transparent);
+ color: #8a5cf5;
+}
+
+.ps-entity-card-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--ps-text);
+ margin: 0;
+ word-break: break-word;
+}
+
+.ps-entity-card-sub {
+ font-size: 13px;
+ color: var(--ps-text-muted);
+ margin: 0;
+}
+
+.ps-entity-card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 2px;
+}
+
+.ps-entity-tag {
+ font-size: 11px;
+ padding: 1px 7px;
+ border-radius: 999px;
+ background: var(--ps-surface);
+ color: var(--ps-text-muted);
+ border: 1px solid var(--ps-border);
+}
+
+/* ---- Relationship map ---- */
+.ps-entity-map {
+ padding: 8px;
+ display: flex;
+ justify-content: center;
+}
+
+.ps-relationship-map {
+ width: 100%;
+ max-width: 640px;
+ height: auto;
+ border: 1px solid var(--ps-border);
+ border-radius: 10px;
+ background: var(--ps-surface-2);
+}
+
+.ps-relationship-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 420px;
+ color: var(--ps-text-muted);
+ font-size: 15px;
+}
+
+.ps-relationship-node {
+ transition: transform 0.1s;
+}
+
+.ps-relationship-label {
+ font-family: var(--ps-font-ui);
+}
+
+/* ---- Entity editor drawer ---- */
+.ps-entity-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.ps-entity-editor-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--ps-border);
+}
+
+.ps-spacer {
+ flex: 1;
+}
+
+.ps-entity-delete {
+ margin-right: auto;
+}
+
+/* ---- Generic entity fields ---- */
+.ps-entity-fields {
+ display: flex;
+ flex-direction: column;
+}
+
+.ps-field-span-2 {
+ grid-column: span 2;
+}
+
+.ps-textarea {
+ resize: vertical;
+ min-height: 60px;
+ font-family: var(--ps-font-ui);
+ line-height: 1.5;
+}
+
+.ps-field-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--ps-text);
+}
+
+/* ---- Tag field ---- */
+.ps-tag-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ps-tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.ps-tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ padding: 2px 6px 2px 10px;
+ border-radius: 999px;
+ background: var(--ps-surface);
+ border: 1px solid var(--ps-border);
+ color: var(--ps-text);
+}
+
+.ps-tag-remove {
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: var(--ps-text-muted);
+ font-size: 15px;
+ line-height: 1;
+ padding: 0 2px;
+}
+
+.ps-tag-remove:hover {
+ color: var(--ps-danger);
+}
+
+/* ---- Relationship editor ---- */
+.ps-relationship-editor {
+ margin-top: 8px;
+ padding-top: 16px;
+ border-top: 1px solid var(--ps-border);
+}
+
+.ps-relationship-list {
+ list-style: none;
+ margin: 8px 0 12px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ps-relationship-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--ps-surface-2);
+ border: 1px solid var(--ps-border);
+ border-radius: 8px;
+}
+
+.ps-relationship-kind {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: capitalize;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
+ color: var(--ps-accent);
+}
+
+.ps-relationship-target {
+ flex: 1;
+ font-size: 14px;
+ color: var(--ps-text);
+}
+
+.ps-relationship-remove {
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: var(--ps-text-muted);
+ font-size: 18px;
+ line-height: 1;
+ padding: 0 4px;
+}
+
+.ps-relationship-remove:hover {
+ color: var(--ps-danger);
+}
+
+.ps-relationship-add {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.ps-relationship-target-select,
+.ps-relationship-kind-select {
+ flex: 1;
+ min-width: 120px;
+}
+
+.ps-relationship-note {
+ flex: 2;
+ min-width: 120px;
+}
+
+/* ---- Misc ---- */
+.ps-muted {
+ color: var(--ps-text-muted);
+ font-size: 14px;
+}
+
+/* ---- Toolbar + tabs ---- */
+.ps-entity-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.ps-entity-tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--ps-border);
+ padding-bottom: -1px;
+}
+
+.ps-entity-tab {
+ background: none;
+ border: none;
+ padding: 8px 12px;
+ font-size: 14px;
+ font-family: var(--ps-font-ui);
+ color: var(--ps-text-muted);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.ps-entity-tab:hover {
+ color: var(--ps-text);
+}
+
+.ps-entity-tab.active {
+ color: var(--ps-text);
+ border-bottom-color: var(--ps-accent);
+ font-weight: 600;
+}
+
+.ps-entity-new-kind {
+ min-width: 120px;
+}
+
+/* ---- Entity editor drawer header/body ---- */
+.ps-entity-editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 4px;
+}
+
+.ps-entity-editor-header h2 {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0;
+ color: var(--ps-text);
+}
+
+.ps-entity-editor-body {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ padding: 4px 2px;
+}
+
+.ps-icon-btn {
+ border: none;
+ background: none;
+ font-size: 22px;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--ps-text-muted);
+ padding: 0 4px;
+}
+
+.ps-icon-btn:hover {
+ color: var(--ps-text);
+}
+
+/* ---- Relationship section + map ---- */
+.ps-relationship-section {
+ margin-top: 8px;
+ padding-top: 16px;
+ border-top: 1px solid var(--ps-border);
+}
+
+.ps-relationship-section h3 {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--ps-text-muted);
+ margin: 0 0 8px;
+}
+
+.ps-relationship-map-wrap {
+ margin-top: 12px;
+ display: flex;
+ justify-content: center;
+}
+
+.ps-relationship-svg {
+ width: 100%;
+ max-width: 520px;
+ height: auto;
+}
+
+.ps-relationship-map-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 200px;
+ color: var(--ps-text-muted);
+ font-size: 14px;
+}
+
+.ps-rel-edge {
+ stroke: var(--ps-border);
+ stroke-width: 1.5;
+}
+
+.ps-rel-node {
+ stroke: var(--ps-surface);
+ stroke-width: 2;
+ transition: transform 0.1s;
+}
+
+.ps-rel-node-label {
+ font-family: var(--ps-font-ui);
+ font-size: 10px;
+ fill: var(--ps-text-muted);
+ pointer-events: none;
+}
diff --git a/packages/core/src/autosave.test.ts b/packages/core/src/autosave.test.ts
index fb6acbe..27c6b4b 100644
--- a/packages/core/src/autosave.test.ts
+++ b/packages/core/src/autosave.test.ts
@@ -29,6 +29,16 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
return null;
},
async saveEntity() {},
+ async listEntities() {
+ return [];
+ },
+ async getEntity() {
+ return null;
+ },
+ async deleteEntity() {},
+ async searchEntities() {
+ return [];
+ },
async saveBoard() {},
async saveAISession() {},
async saveDocument(doc: DocumentRef) {
diff --git a/packages/core/src/backlinks.ts b/packages/core/src/backlinks.ts
index 225f68f..0565fbc 100644
--- a/packages/core/src/backlinks.ts
+++ b/packages/core/src/backlinks.ts
@@ -82,3 +82,21 @@ export function docHasMentions(doc: ProseMirrorDoc): boolean {
visit(doc.content ?? []);
return found;
}
+
+/**
+ * Compute the backlinks for a single entity across a set of chapters: every
+ * chapter that references it, with the node paths where it appears.
+ */
+export function backlinksForEntity(
+ chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
+ entityId: string,
+): Backlink[] {
+ const links: Backlink[] = [];
+ for (const ch of chapters) {
+ const paths = findMentionsInDoc(ch.doc, new Set([entityId]), new Set());
+ if (paths.length > 0) {
+ links.push({ chapterId: ch.id, title: ch.title, paths });
+ }
+ }
+ return links;
+}
diff --git a/packages/core/src/consistency.test.ts b/packages/core/src/consistency.test.ts
new file mode 100644
index 0000000..ca38da8
--- /dev/null
+++ b/packages/core/src/consistency.test.ts
@@ -0,0 +1,171 @@
+import { describe, it, expect } from "vitest";
+import {
+ findOrphanedMentions,
+ parseRelationships,
+ findRelationshipAsymmetry,
+ findInconsistencies,
+ SYMMETRIC_RELATIONSHIPS,
+} from "./consistency.js";
+import type { Character, Entity, ProseMirrorDoc } from "./types.js";
+
+const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "She said " },
+ { type: "CharacterMention", attrs: entityId ? { entityId, name } : { name } },
+ ],
+ },
+ ],
+});
+
+describe("findOrphanedMentions", () => {
+ it("flags a mention whose entity id is gone", () => {
+ const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
+ const issues = findOrphanedMentions(chapters, new Set(["ada"]));
+ expect(issues).toHaveLength(1);
+ expect(issues[0]).toMatchObject({ kind: "orphan-mention", entityId: "ghost", chapterId: "ch1" });
+ });
+
+ it("does not flag a mention whose entity still exists", () => {
+ const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ada") }];
+ expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
+ });
+
+ it("does not flag a bare name mention (no entityId)", () => {
+ const chapters = [{ id: "ch1", title: "Ch 1", doc: doc(null) }];
+ expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
+ });
+});
+
+describe("parseRelationships", () => {
+ it("parses the relationships array into a targetId map", () => {
+ const c: Character = {
+ id: "ada",
+ projectId: "p",
+ name: "Ada",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [
+ { targetId: "maria", kind: "sibling", note: "sisters" },
+ { targetId: "mark", kind: "ally", note: "" },
+ ],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ expect(parseRelationships(c)).toEqual({
+ maria: { kind: "sibling", note: "sisters" },
+ mark: { kind: "ally", note: "" },
+ });
+ });
+});
+
+describe("findRelationshipAsymmetry", () => {
+ const ada: Character = {
+ id: "ada",
+ projectId: "p",
+ name: "Ada",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ const maria: Character = {
+ id: "maria",
+ projectId: "p",
+ name: "Maria",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ const brad: Character = {
+ id: "brad",
+ projectId: "p",
+ name: "Brad",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [{ targetId: "ada", kind: "rival", note: "" }],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ const adaBack: Character = {
+ ...ada,
+ relationships: [
+ { targetId: "maria", kind: "sibling", note: "" },
+ { targetId: "brad", kind: "rival", note: "" },
+ ],
+ };
+
+ it("reports a symmetric relationship that is not reciprocated", () => {
+ const issues = findRelationshipAsymmetry([ada, maria]);
+ expect(issues).toHaveLength(1);
+ expect(issues[0]).toMatchObject({ kind: "relationship-asymmetry", fromEntity: "ada", toEntity: "maria", relationship: "sibling" });
+ });
+
+ it("does not report when the symmetric relationship is reciprocated", () => {
+ const mariaBack: Character = {
+ ...maria,
+ relationships: [{ targetId: "ada", kind: "sibling", note: "" }],
+ };
+ const issues = findRelationshipAsymmetry([adaBack, mariaBack, brad]);
+ expect(issues).toHaveLength(0);
+ });
+
+ it("ignores directed (non-symmetric) relationship kinds", () => {
+ const mentor: Character = {
+ id: "m",
+ projectId: "p",
+ name: "Mentor",
+ role: "",
+ bio: "",
+ appearance: "",
+ voice: "",
+ relationships: [{ targetId: "ada", kind: "mentor", note: "" }],
+ arc: "",
+ status: "active",
+ tags: [],
+ };
+ expect(findRelationshipAsymmetry([ada, mentor])).toHaveLength(0);
+ });
+
+ it("exposes the symmetric kinds", () => {
+ expect(SYMMETRIC_RELATIONSHIPS.has("sibling")).toBe(true);
+ expect(SYMMETRIC_RELATIONSHIPS.has("mentor")).toBe(false);
+ });
+});
+
+describe("findInconsistencies", () => {
+ it("combines orphan-mention and relationship-asymmetry issues", () => {
+ const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
+ const ada: Character = {
+ id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
+ relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
+ arc: "", status: "active", tags: [],
+ };
+ const maria: Character = {
+ id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
+ relationships: [], arc: "", status: "active", tags: [],
+ };
+ const entities: Entity[] = [ada, maria];
+ const issues = findInconsistencies(chapters, entities);
+ expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true);
+ expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true);
+ });
+});
diff --git a/packages/core/src/consistency.ts b/packages/core/src/consistency.ts
new file mode 100644
index 0000000..2c51986
--- /dev/null
+++ b/packages/core/src/consistency.ts
@@ -0,0 +1,130 @@
+/**
+ * Consistency checks across entities and chapters. These catch the kinds of
+ * contradictions that creep in as a manuscript grows: a character mentioned in
+ * prose whose entity was deleted, or a relationship that only one side records.
+ * See CHUNK #4 step 2.4.
+ */
+import type { Character, Entity, ProseMirrorDoc } from "./types.js";
+
+export type RelationshipSymmetry = "symmetric" | "directed";
+
+/**
+ * Relationship kinds that should hold in both directions. If Ada lists "Bram:
+ * sibling", then Bram should list "Ada: sibling" too.
+ */
+export const SYMMETRIC_RELATIONSHIPS = new Set(["sibling", "friend", "spouse", "partner", "family", "rival"]);
+
+export interface MentionIssue {
+ kind: "orphan-mention";
+ chapterId: string;
+ chapterTitle: string;
+ entityId: string;
+ /** The mention text, if any. */
+ name?: string | undefined;
+}
+
+export interface RelationshipIssue {
+ kind: "relationship-asymmetry";
+ fromEntity: string;
+ fromName: string;
+ toEntity: string;
+ toName: string;
+ relationship: string;
+}
+
+export type ConsistencyIssue = MentionIssue | RelationshipIssue;
+
+/**
+ * Find mention nodes that reference an entity id that no longer exists (e.g.
+ * the entity was deleted). Returns one issue per offending mention.
+ */
+export function findOrphanedMentions(
+ chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
+ entityIds: Set,
+): MentionIssue[] {
+ const issues: MentionIssue[] = [];
+ for (const ch of chapters) {
+ const visit = (content: unknown[], _path: number[]): void => {
+ for (const node of content as Record[]) {
+ if (!node || typeof node !== "object") continue;
+ if (node.type === "CharacterMention") {
+ const attrs = node.attrs as { entityId?: string; name?: string } | undefined;
+ if (attrs?.entityId && !entityIds.has(attrs.entityId)) {
+ issues.push({
+ kind: "orphan-mention",
+ chapterId: ch.id,
+ chapterTitle: ch.title,
+ entityId: attrs.entityId,
+ name: attrs.name,
+ });
+ }
+ }
+ if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
+ }
+ };
+ visit(ch.doc.content ?? [], []);
+ }
+ return issues;
+}
+
+/**
+ * Parse the `relationships` array of a character into
+ * `{ [targetId]: { kind, note } }`.
+ */
+export function parseRelationships(character: Character): Record {
+ const out: Record = {};
+ for (const r of character.relationships ?? []) {
+ out[r.targetId] = { kind: r.kind, note: r.note };
+ }
+ return out;
+}
+
+/**
+ * Find relationships recorded by one character but not reciprocated by the
+ * other, for symmetric relationship kinds. Returns one issue per asymmetric
+ * pair (reported once, from the recording side).
+ */
+export function findRelationshipAsymmetry(entities: Entity[]): RelationshipIssue[] {
+ const characters = entities.filter((e): e is Character => !("fields" in e) && !("body" in e));
+ // index by id -> character (targetId is an entity id)
+ const byId = new Map();
+ for (const c of characters) byId.set(c.id, c);
+
+ const issues: RelationshipIssue[] = [];
+ for (const c of characters) {
+ const rels = parseRelationships(c);
+ for (const [targetId, rel] of Object.entries(rels)) {
+ const sym = rel.kind.toLowerCase();
+ if (!SYMMETRIC_RELATIONSHIPS.has(sym)) continue;
+ const other = byId.get(targetId);
+ if (!other || other.id === c.id) continue;
+ // Does the other side record this character back with a symmetric kind?
+ const otherRel = parseRelationships(other);
+ const back = otherRel[c.id];
+ if (!back || !SYMMETRIC_RELATIONSHIPS.has(back.kind.toLowerCase())) {
+ issues.push({
+ kind: "relationship-asymmetry",
+ fromEntity: c.id,
+ fromName: c.name,
+ toEntity: other.id,
+ toName: other.name,
+ relationship: rel.kind,
+ });
+ }
+ }
+ }
+ return issues;
+}
+
+/** Run every consistency check and return the combined issue list. */
+export function findInconsistencies(
+ chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
+ entities: Entity[],
+): ConsistencyIssue[] {
+ const entityIds = new Set(entities.map((e) => e.id));
+ const issues: ConsistencyIssue[] = [
+ ...findOrphanedMentions(chapters, entityIds),
+ ...findRelationshipAsymmetry(entities),
+ ];
+ return issues;
+}
diff --git a/packages/core/src/fuzzy.test.ts b/packages/core/src/fuzzy.test.ts
new file mode 100644
index 0000000..5cd7f28
--- /dev/null
+++ b/packages/core/src/fuzzy.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from "vitest";
+import { fuzzyScore, fuzzyMatch, fuzzySort } from "./fuzzy.js";
+
+describe("fuzzyScore", () => {
+ it("matches an empty query as no-match (score 0)", () => {
+ expect(fuzzyScore("", "mara").score).toBe(0);
+ });
+
+ it("matches a whole-string query at score 1", () => {
+ const r = fuzzyScore("mara", "Mara");
+ expect(r.score).toBe(1);
+ expect(r.indices).toEqual([0, 1, 2, 3]);
+ });
+
+ it("matches a subsequence in order", () => {
+ const r = fuzzyScore("mr", "Mara");
+ expect(r.score).toBeGreaterThan(0);
+ expect(r.indices).toEqual([0, 2]);
+ });
+
+ it("does not match a non-subsequence", () => {
+ expect(fuzzyScore("z", "Mara").score).toBe(0);
+ expect(fuzzyScore("marz", "Mara").score).toBe(0);
+ });
+
+ it("scores consecutive matches higher than scattered ones", () => {
+ const consecutive = fuzzyScore("mar", "maria").score;
+ const scattered = fuzzyScore("mr", "maria").score;
+ expect(consecutive).toBeGreaterThan(scattered);
+ });
+
+ it("scores word-start matches higher", () => {
+ const wordStart = fuzzyScore("h", "harbor").score;
+ const mid = fuzzyScore("r", "harbor").score;
+ expect(wordStart).toBeGreaterThan(mid);
+ });
+});
+
+describe("fuzzyMatch", () => {
+ it("is true for a subsequence and false otherwise", () => {
+ expect(fuzzyMatch("mr", "Mara")).toBe(true);
+ expect(fuzzyMatch("z", "Mara")).toBe(false);
+ });
+});
+
+describe("fuzzySort", () => {
+ const items = [
+ { id: "ada", name: "Ada Lovelace" },
+ { id: "maria", name: "Maria Harbor" },
+ { id: "mark", name: "Mark Twain" },
+ ];
+
+ it("returns all items for an empty query, preserving order", () => {
+ expect(fuzzySort(items, "", (i) => i.name).map((i) => i.id)).toEqual(["ada", "maria", "mark"]);
+ });
+
+ it("drops non-matches and orders by descending score", () => {
+ const sorted = fuzzySort(items, "mar", (i) => i.name);
+ expect(sorted.map((i) => i.id)).toContain("maria");
+ expect(sorted.map((i) => i.id)).not.toContain("ada");
+ // "Maria Harbor" (word start) should beat "Mark Twain" (word start) only on tie-break; both match.
+ expect(sorted.length).toBe(2);
+ });
+
+ it("is stable for equal scores", () => {
+ // "ada" and "amy" both score 2 for query "a" (word-start match); input
+ // order must be preserved.
+ const stable = [
+ { id: "a", name: "ada" },
+ { id: "b", name: "amy" },
+ ];
+ const sorted = fuzzySort(stable, "a", (i) => i.name);
+ expect(sorted.map((i) => i.id)).toEqual(["a", "b"]);
+ });
+});
diff --git a/packages/core/src/fuzzy.ts b/packages/core/src/fuzzy.ts
new file mode 100644
index 0000000..2d17ed3
--- /dev/null
+++ b/packages/core/src/fuzzy.ts
@@ -0,0 +1,111 @@
+/**
+ * Dependency-light fuzzy scorer used for entity search and mention
+ * autocomplete.
+ *
+ * This is a subsequence scorer in the spirit of "sublime" / Fuse.js: a query is
+ * a sequence of characters that must appear (in order) within a target string.
+ * It scores matches so that:
+ *
+ * - consecutive matches score higher than scattered ones,
+ * - matches at the start of a word (after a separator) score higher,
+ * - earlier matches in the string score higher.
+ *
+ * The result is a number in `[0, 1]` where `1` is a perfect (whole-string,
+ * case-insensitive, consecutive) match. It is intentionally allocation-light
+ * and fast enough to run on every keystroke against a few hundred entities
+ * well under the 50 ms budget the plan calls for.
+ */
+
+const WORD_SEP = /[^a-zA-Z0-9]+/;
+
+export interface FuzzyResult {
+ /** Match score in [0, 1]; 0 means no match. */
+ score: number;
+ /** Indices in `target` that were matched by the query, in order. */
+ indices: number[];
+}
+
+/** Score a single matched cell from its predecessor score. */
+function scoreCell(prev: number, consecutive: number, wordStart: number): number {
+ let s = prev + 1; // base reward for a match
+ if (consecutive) s *= 1.4; // multiplicative streak bonus
+ if (wordStart) s += 0.8; // word-start bonus
+ return s;
+}
+
+/** Normalize a raw score into [0, 1]. */
+function normalize(raw: number): number {
+ if (!isFinite(raw)) return 0;
+ return Math.max(0, Math.min(1, raw / 3));
+}
+
+/**
+ * Score `query` against `target`. Returns `{ score: 0 }` when the query is not a
+ * subsequence of the target (i.e. no match).
+ */
+export function fuzzyScore(query: string, target: string): FuzzyResult {
+ const q = query;
+ const t = target;
+ if (q.length === 0) return { score: 0, indices: [] };
+ if (q.length > t.length) return { score: 0, indices: [] };
+
+ const ql = q.length;
+ const tl = t.length;
+ const tq = q.toLowerCase();
+ const tt = t.toLowerCase();
+
+ // best[i] = best score having matched the first i query chars.
+ // where[i] = target index at which the i-th match landed (for recovery).
+ const best = new Float64Array(ql + 1).fill(-Infinity);
+ const where = new Int32Array(ql + 1);
+ const consec = new Uint8Array(ql + 1);
+ best[0] = 0;
+
+ for (let ti = 0; ti < tl; ti++) {
+ const tc = tt[ti]!;
+ // Walk from high to low so each cell reads the previous row only.
+ for (let i = ql; i >= 1; i--) {
+ const qc = tq[i - 1]!;
+ if (tc !== qc) continue;
+ const fromPrev = best[i - 1]!;
+ if (fromPrev <= -Infinity) continue;
+ const wordStart = ti === 0 || WORD_SEP.test(t[ti - 1]!) ? 1 : 0;
+ const c = consec[i - 1] ? 1 : 0;
+ const s = scoreCell(fromPrev, c, wordStart);
+ if (s > best[i]!) {
+ best[i] = s;
+ where[i] = ti;
+ consec[i] = c;
+ }
+ }
+ }
+
+ if (best[ql]! <= -Infinity) return { score: 0, indices: [] };
+
+ // Recover matched indices by walking `where` backwards.
+ const indices: number[] = [];
+ for (let i = ql; i >= 1; i--) {
+ indices.push(where[i]!);
+ }
+ indices.reverse();
+
+ return { score: normalize(best[ql]!), indices };
+}
+
+/** Convenience: does `query` match `target` as a subsequence? */
+export function fuzzyMatch(query: string, target: string): boolean {
+ return fuzzyScore(query, target).score > 0;
+}
+
+/**
+ * Sort `items` by descending fuzzy score against `query`, dropping non-matches.
+ * Stable: equal scores keep input order.
+ */
+export function fuzzySort(items: T[], query: string, pick: (item: T) => string): T[] {
+ if (!query) return items.slice();
+ const scored = items
+ .map((item) => ({ item, res: fuzzyScore(query, pick(item)) }))
+ .filter((r) => r.res.score > 0);
+ scored.sort((a, b) => b.res.score - a.res.score);
+ return scored.map((r) => r.item);
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 737e543..8128673 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -12,4 +12,6 @@ export * from "./schema.js";
export * from "./schemas.js";
export * from "./autosave.js";
export * from "./backlinks.js";
+export * from "./fuzzy.js";
+export * from "./consistency.js";
export * from "./storage-server.js";
diff --git a/packages/core/src/storage-local.ts b/packages/core/src/storage-local.ts
index e1d28f2..76e6418 100644
--- a/packages/core/src/storage-local.ts
+++ b/packages/core/src/storage-local.ts
@@ -13,6 +13,7 @@ import type {
import type { StorageAdapter } from "./storage.js";
import { createUlid } from "./ids.js";
import { nowIso } from "./time.js";
+import { fuzzySort } from "./fuzzy.js";
interface EntityRec {
key: string;
@@ -49,6 +50,35 @@ const entityKey = (projectId: string, kind: string, id: string): string =>
const kindOf = (e: Entity): string =>
"fields" in e ? "world" : "body" in e ? "note" : "character";
+/**
+ * Concatenate every human-readable field of an entity into a single searchable
+ * string (name + field values). Used by the fuzzy search index.
+ */
+function entitySearchText(e: Entity): string {
+ if ("fields" in e) {
+ // WorldItem
+ const parts = [e.name, e.kind];
+ for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
+ return parts.join(" ");
+ }
+ if ("body" in e) {
+ // Note
+ return [e.body, ...e.tags].join(" ");
+ }
+ // Character
+ return [
+ e.name,
+ e.role,
+ e.bio,
+ e.appearance,
+ e.voice,
+ e.relationships,
+ e.arc,
+ e.status,
+ ...e.tags,
+ ].join(" ");
+}
+
/**
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
* Implements the exact `StorageAdapter` contract so the app can run fully
@@ -136,6 +166,30 @@ export const storageLocal: StorageAdapter = {
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
},
+ async listEntities(projectId: string, kind?: string): Promise {
+ const rows = await db.entities
+ .where("projectId")
+ .equals(projectId)
+ .toArray();
+ const filtered = kind ? rows.filter((r) => r.kind === kind) : rows;
+ return filtered.map((r) => r.data);
+ },
+
+ async getEntity(projectId: string, kind: string, id: string): Promise {
+ const row = await db.entities.get(entityKey(projectId, kind, id));
+ return row ? row.data : null;
+ },
+
+ async deleteEntity(projectId: string, kind: string, id: string): Promise {
+ await db.entities.delete(entityKey(projectId, kind, id));
+ },
+
+ async searchEntities(projectId: string, q: string): Promise {
+ const all = await this.listEntities(projectId);
+ if (!q) return all;
+ return fuzzySort(all, q, entitySearchText);
+ },
+
async saveBoard(b: Board): Promise {
await db.boards.put(b);
},
diff --git a/packages/core/src/storage-server.ts b/packages/core/src/storage-server.ts
index d048fed..3bb6ddb 100644
--- a/packages/core/src/storage-server.ts
+++ b/packages/core/src/storage-server.ts
@@ -118,6 +118,41 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
}
},
+ async listEntities(projectId: string, kind?: string): Promise {
+ const params = new URLSearchParams();
+ if (kind) params.set("kind", kind);
+ const qs = params.toString();
+ const res = await req<{ entities: Entity[] }>(
+ "GET",
+ `/api/projects/${projectId}/entities${qs ? `?${qs}` : ""}`,
+ );
+ return res.entities;
+ },
+
+ async getEntity(projectId: string, kind: string, id: string): Promise {
+ try {
+ const res = await req<{ entity: Entity }>(
+ "GET",
+ `/api/projects/${projectId}/entities/${kind}/${id}`,
+ );
+ return res.entity;
+ } catch {
+ return null;
+ }
+ },
+
+ async deleteEntity(projectId: string, kind: string, id: string): Promise {
+ await req("DELETE", `/api/projects/${projectId}/entities/${kind}/${id}`);
+ },
+
+ async searchEntities(projectId: string, q: string): Promise {
+ const res = await req<{ entities: Entity[] }>(
+ "GET",
+ `/api/projects/${projectId}/entities?q=${encodeURIComponent(q)}`,
+ );
+ return res.entities;
+ },
+
async saveBoard(b: Board): Promise {
await req("POST", `/api/projects/${b.projectId}/boards`, {
id: b.id,
diff --git a/packages/core/src/storage.contract.test.ts b/packages/core/src/storage.contract.test.ts
index fb3756b..ce1ca60 100644
--- a/packages/core/src/storage.contract.test.ts
+++ b/packages/core/src/storage.contract.test.ts
@@ -1,6 +1,6 @@
import "fake-indexeddb/auto";
import { describe, expect, it } from "vitest";
-import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
+import { createServerClient, type ProseMirrorDoc, type Character, type StorageAdapter, type WorldItem } from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
@@ -63,6 +63,31 @@ function mockServerFetch(): typeof fetch {
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
return json({ entity: e });
}
+ if (method === "GET" && m) {
+ const projectId = m[1]!;
+ const kind = u.searchParams.get("kind");
+ const q = u.searchParams.get("q") ?? "";
+ const all = [...entities.values()].filter((e) => e.projectId === projectId && (!kind || e.kind === kind));
+ const filtered = q
+ ? all.filter((e) => JSON.stringify(e).toLowerCase().includes(q.toLowerCase()))
+ : all;
+ return json({ entities: filtered });
+ }
+ m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities\/([^/]+)\/([^/]+)$/);
+ if (method === "GET" && m) {
+ const projectId = m[1]!;
+ const kind = m[2]!;
+ const id = m[3]!;
+ const e = entities.get(`${projectId}|${kind}|${id}`);
+ return e ? json({ entity: e }) : json({ error: "not_found" }, 404);
+ }
+ if (method === "DELETE" && m) {
+ const projectId = m[1]!;
+ const kind = m[2]!;
+ const id = m[3]!;
+ entities.delete(`${projectId}|${kind}|${id}`);
+ return json({ ok: true });
+ }
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
if (method === "POST" && m) {
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
@@ -132,6 +157,85 @@ const EXPECTED = {
chapterRev: 1,
};
+/** Entity CRUD + search scenario both adapters must pass identically. */
+async function entityScenario(adapter: StorageAdapter) {
+ const projectId = "proj-ent";
+ await adapter.createProject({ title: "Entities" });
+
+ const ada: Character = {
+ id: "ada",
+ projectId,
+ name: "Ada Lovelace",
+ role: "protagonist",
+ bio: "A mathematician and first computer programmer.",
+ appearance: "sharp eyes, Victorian dress",
+ voice: "precise, confident",
+ relationships: [{ targetId: "babbage", kind: "ally", note: "worked together" }],
+ arc: "From curiosity to genius.",
+ status: "active",
+ tags: ["math", "victorian"],
+ };
+ const babbage: Character = {
+ id: "babbage",
+ projectId,
+ name: "Charles Babbage",
+ role: "mentor",
+ bio: "An obsessed inventor of computing machines.",
+ appearance: "bearded, bespectacled",
+ voice: "grumbly",
+ relationships: [],
+ arc: "",
+ status: "active",
+ tags: ["inventor"],
+ };
+ const clocktower: WorldItem = {
+ id: "clocktower",
+ projectId,
+ kind: "location",
+ name: "The Clocktower",
+ fields: { era: "1840s", significance: "meeting place" },
+ };
+
+ await adapter.saveEntity(ada);
+ await adapter.saveEntity(babbage);
+ await adapter.saveEntity(clocktower);
+
+ // list all
+ const all = await adapter.listEntities(projectId);
+ expect(all.map((e) => e.id).sort()).toEqual(["ada", "babbage", "clocktower"]);
+
+ // list by kind
+ const chars = await adapter.listEntities(projectId, "character");
+ expect(chars.map((e) => e.id).sort()).toEqual(["ada", "babbage"]);
+
+ // getEntity: found
+ expect(((await adapter.getEntity(projectId, "character", "ada")) as Character | null)?.name).toBe("Ada Lovelace");
+
+ // getEntity: not found
+ expect(await adapter.getEntity(projectId, "character", "nope")).toBeNull();
+
+ // search: fuzzy match, best-first
+ const babbageRes = await adapter.searchEntities(projectId, "babbage");
+ expect(babbageRes.map((e) => e.id)).toContain("babbage");
+ const adaRes = await adapter.searchEntities(projectId, "ada");
+ expect(adaRes[0]?.id).toBe("ada");
+
+ // search: empty returns all
+ expect((await adapter.searchEntities(projectId, "")).length).toBe(3);
+
+ // delete
+ await adapter.deleteEntity(projectId, "character", "ada");
+ expect(await adapter.getEntity(projectId, "character", "ada")).toBeNull();
+ expect((await adapter.listEntities(projectId)).map((e) => e.id)).toEqual(["babbage", "clocktower"]);
+}
+
+describe.each([
+ { name: "local (IndexedDB)", adapter: storageLocal },
+ { name: "server (REST)", adapter: createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() }) },
+])("$name adapter honors the entity contract", async ({ adapter }) => {
+ it("lists, fetches, searches, and deletes entities", () => entityScenario(adapter));
+});
+
describe("StorageAdapter contract (web/local vs server)", () => {
it("local (IndexedDB) adapter honors the contract", async () => {
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);
diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts
index 6de886f..3ef3e0b 100644
--- a/packages/core/src/storage.ts
+++ b/packages/core/src/storage.ts
@@ -40,6 +40,18 @@ export interface StorageAdapter {
saveDocument(doc: DocumentRef): Promise;
getChapter(id: string): Promise;
saveEntity(e: Entity): Promise;
+ /**
+ * List entities for a project, optionally filtered by kind ("character" |
+ * "world" | "note"). Returns every entity when `kind` is omitted.
+ */
+ listEntities(projectId: string, kind?: string): Promise;
+ getEntity(projectId: string, kind: string, id: string): Promise;
+ deleteEntity(projectId: string, kind: string, id: string): Promise;
+ /**
+ * Fuzzy search across an entity's name and field values. Returns matches
+ * ordered best-first; empty `q` returns all entities for the project.
+ */
+ searchEntities(projectId: string, q: string): Promise;
saveBoard(b: Board): Promise;
saveAISession(s: AISession): Promise;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 7e1c40b..a84ae00 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -130,6 +130,10 @@ export type RelationshipKind =
| "ally"
| "rival"
| "family"
+ | "sibling"
+ | "friend"
+ | "spouse"
+ | "partner"
| "mentor"
| "romantic"
| "custom";
@@ -141,7 +145,7 @@ export interface WorldItem {
projectId: string;
kind: WorldItemKind;
name: string;
- fields: Record;
+ fields: Record;
}
export type BeatTemplate =
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 3ea03e3..6b50938 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -44,6 +44,10 @@ export { serializeToMarkdown, parseMarkdown, markdownInputRules, markdownPasteRu
export { throttle, createIdleDetector } from "./perf.js";
+export { MentionAutocomplete, mentionPickerState, findMentionTrigger } from "./mention.js";
+export type { MentionEntity, MentionProvider, MentionPickerState } from "./mention.js";
+export { MentionPicker } from "./nodes/MentionPicker.js";
+
// Re-export the TipTap render surface and editor type so consumers of the
// package never import TipTap directly.
export { EditorContent } from "@tiptap/react";
diff --git a/packages/editor/src/mention.ts b/packages/editor/src/mention.ts
new file mode 100644
index 0000000..8c5c66a
--- /dev/null
+++ b/packages/editor/src/mention.ts
@@ -0,0 +1,115 @@
+/**
+ * Mention autocomplete β the `@` trigger that opens an entity picker and
+ * inserts a `CharacterMention` node.
+ *
+ * The editor package owns the low-level mechanics (detecting `@query`,
+ * positioning the picker, inserting the mention atom). The *data* β which
+ * entities to search β is injected by the app through a `MentionProvider`, so
+ * this package never imports the app's entity store.
+ *
+ * The picker is a React component (`MentionPicker`) that reads a small
+ * module-level `mentionPickerState` object shared with this extension. The
+ * extension exposes an `insertMention` command; the picker calls it on Enter.
+ */
+import { Extension, type Editor, type RawCommands } from "@tiptap/core";
+import type { Node } from "@tiptap/pm/model";
+import { CHARACTER_MENTION } from "./constants.js";
+
+/** A single entity option shown in the autocomplete picker. */
+export interface MentionEntity {
+ id: string;
+ label: string;
+ /** Optional subtitle (role / one-line bio) shown under the name. */
+ subtitle?: string;
+}
+
+/**
+ * The search provider the app injects. Given a query, return matching
+ * entities sorted by relevance (best first). An empty query should return the
+ * full list (or a capped prefix) so the picker shows candidates immediately.
+ */
+export interface MentionProvider {
+ search: (query: string) => MentionEntity[];
+}
+
+/**
+ * Shared picker state between the extension and the React picker. Kept at module
+ * scope because the TipTap extension is a singleton and the picker is rendered
+ * by the app alongside the editor β there is no cleaner channel between them.
+ */
+export interface MentionPickerState {
+ open: boolean;
+ query: string;
+ /** Doc position of the `@` trigger (where the mention will be inserted). */
+ atPos: number;
+ /** Cursor position (end of the query). */
+ from: number;
+ /** Currently highlighted result index. */
+ selectedIndex: number;
+}
+
+export const mentionPickerState: MentionPickerState = {
+ open: false,
+ query: "",
+ atPos: 0,
+ from: 0,
+ selectedIndex: 0,
+};
+
+/**
+ * Walk backwards from `from` over the doc text looking for the `@` trigger.
+ * Returns the trigger position, or `-1` when the text before the cursor is not
+ * a live mention query (no `@`, or a space/newline interrupts it).
+ */
+export function findMentionTrigger(doc: Node, from: number): number {
+ let atPos = from;
+ while (atPos > 0) {
+ const ch = doc.textBetween(atPos - 1, atPos) ?? "";
+ if (ch === "@") return atPos - 1;
+ if (ch === " " || ch === "\n" || ch === "\t") return -1;
+ atPos -= 1;
+ }
+ return -1;
+}
+
+/**
+ * The autocomplete extension. Adds the `insertMention` command used by the
+ * picker and (optionally) a keyboard shortcut to open the picker manually.
+ */
+export const MentionAutocomplete = Extension.create({
+ name: "mentionAutocomplete",
+
+ addOptions() {
+ return {
+ /** The trigger character. */
+ trigger: "@",
+ };
+ },
+
+ addCommands() {
+ return {
+ /**
+ * Insert a `CharacterMention` atom at the current trigger, replacing the
+ * `@query` text between the trigger and the cursor.
+ */
+ insertMention:
+ (id: string, label: string) =>
+ ({ editor }: { editor: Editor }) => {
+ const { state, schema } = editor;
+ const { from } = state.selection;
+ const atPos = findMentionTrigger(state.doc, from);
+ if (atPos < 0) return false;
+
+ const nodeType = schema.nodes[CHARACTER_MENTION];
+ if (!nodeType) return false;
+
+ const mention = nodeType.create({ id, label });
+ const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention);
+ editor.view.dispatch(tr);
+ return true;
+ },
+ } as unknown as Partial;
+ },
+});
+
+export default MentionAutocomplete;
diff --git a/packages/editor/src/nodes/MentionPicker.tsx b/packages/editor/src/nodes/MentionPicker.tsx
new file mode 100644
index 0000000..a4bf354
--- /dev/null
+++ b/packages/editor/src/nodes/MentionPicker.tsx
@@ -0,0 +1,182 @@
+/**
+ * MentionPicker β the React surface for the `@` autocomplete.
+ *
+ * It watches the editor for a live `@query` (via `editor.on("update")`), runs
+ * the injected `MentionProvider.search`, and renders a floating list positioned
+ * at the cursor. Arrow keys move the highlight, Enter/Click insert the mention,
+ * Escape closes. The actual insertion goes through the editor's `insertMention`
+ * command (see `mention.ts`).
+ *
+ * Render this once, next to ``, passing the editor and a
+ * provider. The provider is supplied by the app so this package never touches
+ * the entity store directly.
+ */
+import { useEffect, useRef, useState } from "react";
+import type { Editor } from "@tiptap/core";
+import { mentionPickerState, findMentionTrigger, type MentionProvider } from "../mention.js";
+
+interface MentionPickerProps {
+ editor: Editor | null;
+ provider: MentionProvider | null;
+}
+
+interface Candidate {
+ id: string;
+ label: string;
+ subtitle?: string;
+}
+
+// The `insertMention` command is custom (not in TipTap's RawCommands), so we
+// reach it through a typed cast.
+type InsertMention = (id: string, label: string) => boolean;
+
+export function MentionPicker({ editor, provider }: MentionPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [candidates, setCandidates] = useState([]);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [pos, setPos] = useState({ x: 0, y: 0 });
+ const listRef = useRef(null);
+
+ // Detect `@query` on every editor update and drive the picker state.
+ useEffect(() => {
+ if (!editor || !provider) return;
+
+ const onUpdate = () => {
+ const { from } = editor.state.selection;
+ const atPos = findMentionTrigger(editor.state.doc, from);
+ if (atPos < 0) {
+ setOpen(false);
+ mentionPickerState.open = false;
+ return;
+ }
+
+ const queryText = editor.state.doc.textBetween(atPos + 1, from) ?? "";
+ const coords = editor.view.coordsAtPos(from);
+ setPos({ x: coords.left, y: coords.bottom });
+ setOpen(true);
+ mentionPickerState.open = true;
+ mentionPickerState.query = queryText;
+ mentionPickerState.atPos = atPos;
+ mentionPickerState.from = from;
+ mentionPickerState.selectedIndex = 0;
+
+ const matches = provider.search(queryText);
+ setCandidates(matches);
+ setSelectedIndex(0);
+ };
+
+ editor.on("update", onUpdate);
+ return () => {
+ editor.off("update", onUpdate);
+ };
+ }, [editor, provider]);
+
+ // Auto-scroll the highlighted item into view.
+ useEffect(() => {
+ listRef.current?.children[selectedIndex]?.scrollIntoView({ block: "nearest" });
+ }, [selectedIndex]);
+
+ // Keep focus on the list so keyboard events are captured while open.
+ useEffect(() => {
+ if (open) listRef.current?.focus();
+ }, [open]);
+
+ const insert = (c: Candidate) => {
+ const cmd = (editor?.commands as unknown as { insertMention: InsertMention }).insertMention;
+ if (cmd) cmd(c.id, c.label);
+ setOpen(false);
+ mentionPickerState.open = false;
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((i) => (i + 1) % Math.max(1, candidates.length));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((i) => (i - 1 + candidates.length) % Math.max(1, candidates.length));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const sel = candidates[selectedIndex];
+ if (sel) insert(sel);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setOpen(false);
+ mentionPickerState.open = false;
+ }
+ };
+
+ if (!open || !provider) return null;
+
+ return (
+
+
+ {candidates.length === 0 ? (
+ -
+ No matches
+
+ ) : (
+ candidates.map((c, i) => (
+ - {
+ e.preventDefault();
+ insert(c);
+ }}
+ onMouseEnter={() => setSelectedIndex(i)}
+ style={{
+ padding: "8px 12px",
+ borderRadius: 6,
+ cursor: "pointer",
+ background: i === selectedIndex ? "var(--ps-accent, #0f3d3e)" : "transparent",
+ color: i === selectedIndex ? "#fff" : "inherit",
+ }}
+ >
+
{c.label}
+ {c.subtitle && (
+
+ {c.subtitle}
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
+export default MentionPicker;
diff --git a/packages/editor/src/schema.ts b/packages/editor/src/schema.ts
index 20c8de9..80ef815 100644
--- a/packages/editor/src/schema.ts
+++ b/packages/editor/src/schema.ts
@@ -23,6 +23,7 @@ import SceneBreakNode from "./nodes/SceneBreak.js";
import FootnoteInline from "./nodes/FootnoteInline.js";
import FootnoteArea from "./nodes/FootnoteArea.js";
import CharacterMention from "./nodes/CharacterMention.js";
+import { MentionAutocomplete } from "./mention.js";
import {
SCENE_BREAK,
@@ -49,6 +50,7 @@ export const pubScriptExtensions = [
FootnoteInline,
FootnoteArea,
CharacterMention,
+ MentionAutocomplete,
];
/**
diff --git a/packages/ui/src/AppShell.css b/packages/ui/src/AppShell.css
index cc775a9..d27a664 100644
--- a/packages/ui/src/AppShell.css
+++ b/packages/ui/src/AppShell.css
@@ -126,6 +126,115 @@ body {
line-height: var(--ps-font-leading, 1.75);
}
+/* ---- Writing surface: toolbar, focus/typewriter modes ---- */
+.ps-writing {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+.ps-writing-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 16px;
+ border-bottom: 1px solid var(--ps-border);
+ background: var(--ps-surface);
+ flex: none;
+}
+.ps-chapter-select {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.ps-chapter-select-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ps-text-muted);
+}
+.ps-chapter-select select {
+ padding: 6px 10px;
+ border: 1px solid var(--ps-border);
+ border-radius: 8px;
+ background: var(--ps-canvas);
+ color: var(--ps-text);
+ font-size: 13px;
+ font-family: inherit;
+}
+.ps-toolbar-group {
+ display: flex;
+ gap: 4px;
+}
+.ps-tool {
+ padding: 5px 12px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--ps-text-muted);
+ font-size: 13px;
+ font-family: inherit;
+ cursor: pointer;
+}
+.ps-tool:hover {
+ color: var(--ps-text);
+ background: var(--ps-canvas);
+}
+.ps-tool.is-active {
+ color: var(--ps-text);
+ border-color: var(--ps-accent);
+ background: color-mix(in srgb, var(--ps-accent) 12%, transparent);
+}
+.ps-autosave-indicator {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ color: var(--ps-text-muted);
+}
+.ps-autosave-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--ps-text-muted);
+}
+.ps-autosave-indicator[data-status="saving"] .ps-autosave-dot {
+ background: #d9a441;
+}
+.ps-autosave-indicator[data-status="dirty"] .ps-autosave-dot {
+ background: #d96c4b;
+}
+.ps-autosave-indicator[data-status="saved"] .ps-autosave-dot {
+ background: #4f9d69;
+}
+
+/* Focus mode: dim the whole document, then restore the block under the cursor. */
+.ps-editor-focus .ps-editor-content {
+ opacity: 0.22;
+ transition: opacity 0.15s ease;
+}
+.ps-editor-focus .ps-editor-content .ps-focus-active,
+.ps-editor-focus .ps-editor-content .ps-focus-active * {
+ opacity: 1;
+ transition: opacity 0.05s ease;
+}
+
+.ps-writing-status {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ padding: 6px 16px;
+ border-top: 1px solid var(--ps-border);
+ background: var(--ps-surface);
+ color: var(--ps-text-muted);
+ font-size: 12px;
+ flex: none;
+}
+.ps-writing-status-note {
+ margin-left: auto;
+ color: var(--ps-text);
+}
+
/* ---- Sidebar nav ---- */
.ps-logo {
font-weight: 700;
diff --git a/server/src/index.ts b/server/src/index.ts
index a37e253..5a15ce1 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,3 +1,4 @@
+import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
@@ -74,7 +75,16 @@ export async function start() {
return app;
}
-start().catch((err) => {
- console.error("Failed to start server:", err);
- process.exit(1);
-});
+// Auto-boot only when this file is the CLI entry point (pnpm dev / start),
+// not when imported by tests (vitest) β otherwise start() would try to bind
+// the port and tear the test process down.
+const invokedDirectly =
+ process.argv[1] !== undefined &&
+ fileURLToPath(import.meta.url) === process.argv[1];
+
+if (invokedDirectly) {
+ start().catch((err) => {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ });
+}
diff --git a/server/src/mapping.ts b/server/src/mapping.ts
index 8018f19..9eef45e 100644
--- a/server/src/mapping.ts
+++ b/server/src/mapping.ts
@@ -183,7 +183,7 @@ export function toEntity(r: EntityRow): Entity {
bio: f.bio ?? "",
appearance: f.appearance ?? "",
voice: f.voice ?? "",
- relationships: f.relationships ?? "",
+ relationships: typeof f.relationships === "string" ? JSON.parse(f.relationships) : (f.relationships ?? []),
arc: f.arc ?? "",
status: f.status ?? "active",
tags: f.tags ?? [],
@@ -251,6 +251,21 @@ export function entityToRow(e: Entity): Omit `${r.targetId} ${r.kind} ${r.note}`).join(" ");
+ return [c.name, c.role, c.bio, c.appearance, c.voice, relText, c.arc, c.status, ...c.tags].join(" ");
+}
+
/** Count words in a ProseMirror doc (text nodes only). */
export function wordCount(doc: unknown): number {
let count = 0;
diff --git a/server/src/routes.ts b/server/src/routes.ts
index 997dae7..99721cd 100644
--- a/server/src/routes.ts
+++ b/server/src/routes.ts
@@ -15,6 +15,7 @@ import {
toAISession,
toEntity,
entityToRow,
+ entitySearchText,
wordCount,
type ProjectRow,
type ShareRow,
@@ -58,7 +59,7 @@ const entityCreate = z.object({
bio: z.string().optional(),
appearance: z.string().optional(),
voice: z.string().optional(),
- relationships: z.string().optional(),
+ relationships: z.array(z.any()).optional(),
arc: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
tags: z.array(z.string()).optional(),
@@ -315,7 +316,7 @@ export function registerRoutes(app: FastifyInstance): void {
// ---------- Entities (characters / world / notes) ----------
app.get("/api/projects/:id/entities", async (req) => {
const { id } = req.params as { id: string };
- const { kind } = req.query as { kind?: string };
+ const { kind, q } = req.query as { kind?: string; q?: string };
requireProjectAccess(app, req, id);
let rows: EntityRow[];
if (kind) {
@@ -323,7 +324,12 @@ export function registerRoutes(app: FastifyInstance): void {
} else {
rows = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(id) as EntityRow[];
}
- return { entities: rows.map(toEntity) };
+ let entities = rows.map(toEntity);
+ if (q) {
+ const needle = q.toLowerCase();
+ entities = entities.filter((e) => entitySearchText(e).toLowerCase().includes(needle));
+ }
+ return { entities };
});
app.post("/api/projects/:id/entities", async (req) => {
@@ -337,7 +343,7 @@ export function registerRoutes(app: FastifyInstance): void {
if (body.kind === "character") {
entity = {
id: eid, projectId: id, name: body.name ?? "", role: body.role ?? "", bio: body.bio ?? "",
- appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
+ appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [],
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (body.kind === "world") {
@@ -374,7 +380,7 @@ export function registerRoutes(app: FastifyInstance): void {
if (kind === "character") {
entity = {
id, projectId, name: body.name ?? existing.name, role: body.role ?? "", bio: body.bio ?? "",
- appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
+ appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [],
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (kind === "world") {