feat(entity): Chunk #4 data layer + entity UI (characters & world-building)

Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
Schwitzz
2026-10-25 22:42:55 -05:00
parent 81a44b6e95
commit 4efa71515e
36 changed files with 2764 additions and 82 deletions
+10
View File
@@ -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) {
+18
View File
@@ -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;
}
+171
View File
@@ -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);
});
});
+130
View File
@@ -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<string>,
): MentionIssue[] {
const issues: MentionIssue[] = [];
for (const ch of chapters) {
const visit = (content: unknown[], _path: number[]): void => {
for (const node of content as Record<string, unknown>[]) {
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<string, { kind: string; note: string }> {
const out: Record<string, { kind: string; note: string }> = {};
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<string, Character>();
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;
}
+75
View File
@@ -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"]);
});
});
+111
View File
@@ -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<T>(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);
}
+2
View File
@@ -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";
+54
View File
@@ -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<Entity[]> {
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<Entity | null> {
const row = await db.entities.get(entityKey(projectId, kind, id));
return row ? row.data : null;
},
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
await db.entities.delete(entityKey(projectId, kind, id));
},
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
const all = await this.listEntities(projectId);
if (!q) return all;
return fuzzySort(all, q, entitySearchText);
},
async saveBoard(b: Board): Promise<void> {
await db.boards.put(b);
},
+35
View File
@@ -118,6 +118,41 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
}
},
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
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<Entity | null> {
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<void> {
await req("DELETE", `/api/projects/${projectId}/entities/${kind}/${id}`);
},
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
const res = await req<{ entities: Entity[] }>(
"GET",
`/api/projects/${projectId}/entities?q=${encodeURIComponent(q)}`,
);
return res.entities;
},
async saveBoard(b: Board): Promise<void> {
await req("POST", `/api/projects/${b.projectId}/boards`, {
id: b.id,
+105 -1
View File
@@ -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);
+12
View File
@@ -40,6 +40,18 @@ export interface StorageAdapter {
saveDocument(doc: DocumentRef): Promise<void>;
getChapter(id: string): Promise<Chapter | null>;
saveEntity(e: Entity): Promise<void>;
/**
* 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<Entity[]>;
getEntity(projectId: string, kind: string, id: string): Promise<Entity | null>;
deleteEntity(projectId: string, kind: string, id: string): Promise<void>;
/**
* 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<Entity[]>;
saveBoard(b: Board): Promise<void>;
saveAISession(s: AISession): Promise<void>;
+5 -1
View File
@@ -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<string, string>;
fields: Record<string, string | string[]>;
}
export type BeatTemplate =
+4
View File
@@ -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";
+115
View File
@@ -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<RawCommands>;
},
});
export default MentionAutocomplete;
+182
View File
@@ -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 `<EditorContent>`, 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<Candidate[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [pos, setPos] = useState({ x: 0, y: 0 });
const listRef = useRef<HTMLUListElement>(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 (
<div
className="ps-mention-picker"
style={{
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: 1000,
minWidth: 220,
maxWidth: 320,
maxHeight: 280,
overflowY: "auto",
boxSizing: "border-box",
background: "var(--ps-surface, #fff)",
color: "var(--ps-text, #1a1a1a)",
border: "1px solid var(--ps-border, #d8d8d8)",
borderRadius: 8,
boxShadow: "0 6px 20px rgba(0,0,0,0.15)",
}}
onKeyDown={onKeyDown}
>
<ul
ref={listRef}
tabIndex={-1}
style={{ listStyle: "none", margin: 0, padding: 4, outline: "none" }}
>
{candidates.length === 0 ? (
<li className="ps-mention-empty" style={{ padding: "10px 12px", color: "var(--ps-muted, #888)" }}>
No matches
</li>
) : (
candidates.map((c, i) => (
<li
key={c.id}
className={`ps-mention-item${i === selectedIndex ? " is-selected" : ""}`}
onMouseDown={(e) => {
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",
}}
>
<div style={{ fontWeight: 600 }}>{c.label}</div>
{c.subtitle && (
<div
style={{
fontSize: 12,
opacity: i === selectedIndex ? 0.9 : 0.65,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{c.subtitle}
</div>
)}
</li>
))
)}
</ul>
</div>
);
}
export default MentionPicker;
+2
View File
@@ -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,
];
/**
+109
View File
@@ -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;