feat(entity): Chunk #4 remaining items — consistency UI, context rail, palette search, expanded seed & DATA_MODEL

- Consistency tab in EntitiesView surfacing findInconsistencies() results
- ContextRail + MentionPopover wired into WritingView for entity context/hover
- CommandPalette indexes entities and chapters for cross-entity search
- link field type for entity references in character fields
- findDeadCharacterMentions consistency check + tests
- Expand seed with a location, note, and a natural Elias Vell mention so the
  dead-character-after-death case is actually exercised
- Author docs/DATA_MODEL.md documenting core schemas, types, storage contract
- Rename CharacterMention id attr -> entityId to match core backlinks/consistency
This commit is contained in:
Schwitzz
2026-10-26 12:02:03 -05:00
parent 4efa71515e
commit 301e842c5c
17 changed files with 1265 additions and 65 deletions
+7
View File
@@ -55,6 +55,13 @@ export function collectMentionedEntityIds(
return ids;
}
/** Collect the set of entity IDs mentioned in a single document. */
export function collectMentionsInDoc(doc: ProseMirrorDoc): Set<string> {
const ids = new Set<string>();
walkForIds(doc, (entityId) => ids.add(entityId));
return ids;
}
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) {
+56 -3
View File
@@ -3,9 +3,11 @@ import {
findOrphanedMentions,
parseRelationships,
findRelationshipAsymmetry,
findDeadCharacterMentions,
findInconsistencies,
SYMMETRIC_RELATIONSHIPS,
} from "./consistency.js";
import { collectMentionsInDoc } from "./backlinks.js";
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
@@ -152,12 +154,32 @@ describe("findRelationshipAsymmetry", () => {
});
describe("findInconsistencies", () => {
it("combines orphan-mention and relationship-asymmetry issues", () => {
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
it("combines orphan-mention, relationship-asymmetry, and dead-character issues", () => {
// Chapter mentions "ghost" (orphan) and "ada" (dead character still alive in prose).
const chapters = [
{
id: "ch1",
title: "Ch 1",
doc: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Ada met " },
{ type: "CharacterMention", attrs: { entityId: "ada", name: "Ada" } },
{ type: "text", text: " and a " },
{ type: "CharacterMention", attrs: { entityId: "ghost", name: "ghost" } },
],
},
],
} as ProseMirrorDoc,
},
];
const ada: Character = {
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
arc: "", status: "active", tags: [],
arc: "", status: "dead", tags: [],
};
const maria: Character = {
id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
@@ -167,5 +189,36 @@ describe("findInconsistencies", () => {
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);
expect(issues.some((i) => i.kind === "dead-character-mentioned")).toBe(true);
});
it("does not flag a dead character who is not mentioned in any chapter", () => {
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
const ada: Character = {
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
arc: "", status: "dead", tags: [],
relationships: [],
};
const issues = findDeadCharacterMentions(chapters, [ada]);
expect(issues).toHaveLength(0);
});
it("collects every entity id referenced in a doc", () => {
const docWithTwo = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Ada and " },
{ type: "CharacterMention", attrs: { entityId: "maria", name: "Maria" } },
{ type: "text", text: " met " },
{ type: "CharacterMention", attrs: { entityId: "brad", name: "Brad" } },
],
},
],
} as ProseMirrorDoc;
const ids = collectMentionsInDoc(docWithTwo);
expect([...ids].sort()).toEqual(["brad", "maria"]);
});
});
+55 -19
View File
@@ -5,6 +5,7 @@
* See CHUNK #4 step 2.4.
*/
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
import { collectMentionsInDoc } from "./backlinks.js";
export type RelationshipSymmetry = "symmetric" | "directed";
@@ -32,7 +33,16 @@ export interface RelationshipIssue {
relationship: string;
}
export type ConsistencyIssue = MentionIssue | RelationshipIssue;
export interface DeadCharacterIssue {
kind: "dead-character-mentioned";
chapterId: string;
chapterTitle: string;
entityId: string;
/** The mention text, if any. */
name?: string | undefined;
}
export type ConsistencyIssue = MentionIssue | RelationshipIssue | DeadCharacterIssue;
/**
* Find mention nodes that reference an entity id that no longer exists (e.g.
@@ -44,25 +54,50 @@ export function findOrphanedMentions(
): 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);
const mentioned = collectMentionsInDoc(ch.doc);
for (const entityId of mentioned) {
if (!entityIds.has(entityId)) {
issues.push({
kind: "orphan-mention",
chapterId: ch.id,
chapterTitle: ch.title,
entityId,
});
}
};
visit(ch.doc.content ?? [], []);
}
}
return issues;
}
/**
* Find characters who are marked `dead` but are still mentioned (by entity id)
* in a chapter body. A dead character should not appear alive in the story.
*
* Bare-name mentions (no `entityId`) are ignored — a reference to a name that
* is not yet an entity is an orphan-mention concern, not a dead-character one.
*/
export function findDeadCharacterMentions(
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
entities: Entity[],
): DeadCharacterIssue[] {
const dead = new Set(
entities
.filter((e): e is Character => !("fields" in e) && !("body" in e) && e.status === "dead")
.map((c) => c.id),
);
const issues: DeadCharacterIssue[] = [];
for (const chapter of chapters) {
const mentioned = collectMentionsInDoc(chapter.doc);
for (const entityId of mentioned) {
if (dead.has(entityId)) {
issues.push({
kind: "dead-character-mentioned",
chapterId: chapter.id,
chapterTitle: chapter.title,
entityId,
});
}
}
}
return issues;
}
@@ -125,6 +160,7 @@ export function findInconsistencies(
const issues: ConsistencyIssue[] = [
...findOrphanedMentions(chapters, entityIds),
...findRelationshipAsymmetry(entities),
...findDeadCharacterMentions(chapters, entities),
];
return issues;
}
+2 -1
View File
@@ -29,11 +29,12 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "home", label: "Home", type: "link", hint: "linked location" },
{ key: "bio", label: "Bio", type: "textarea" },
{ key: "appearance", label: "Appearance", type: "textarea" },
{ key: "voice", label: "Voice", type: "textarea" },
{ key: "arc", label: "Arc", type: "textarea" },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "tags", label: "Tags", type: "list" },
],
},
+1 -1
View File
@@ -103,7 +103,7 @@ export const MentionAutocomplete = Extension.create({
const nodeType = schema.nodes[CHARACTER_MENTION];
if (!nodeType) return false;
const mention = nodeType.create({ id, label });
const mention = nodeType.create({ entityId: id, label });
const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention);
editor.view.dispatch(tr);
return true;
@@ -3,7 +3,7 @@ import { CHARACTER_MENTION, colorAttribute } from "../constants.js";
/**
* Character mention — an inline atom linking to a character entity.
* Attributes: `id` (character id) and `label` (display name). Inline in HTML
* Attributes: `entityId` (character id) and `label` (display name). Inline in HTML
* as `<span class="mention mention-character" data-id="...">@Name</span>`.
*/
export const CharacterMention = Node.create({
@@ -14,11 +14,11 @@ export const CharacterMention = Node.create({
addAttributes() {
return {
id: {
entityId: {
default: null,
isRequired: true,
parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => ({ "data-id": attributes.id }),
renderHTML: (attributes) => ({ "data-id": attributes.entityId }),
},
label: {
default: "",
@@ -36,9 +36,9 @@ export const CharacterMention = Node.create({
renderHTML({ node }) {
return [
"span",
{ class: "mention mention-character", "data-id": node.attrs.id, "data-label": node.attrs.label },
{ class: "mention mention-character", "data-id": node.attrs.entityId, "data-label": node.attrs.label },
"@",
node.attrs.label ?? node.attrs.id,
node.attrs.label ?? node.attrs.entityId,
];
},
});
+10
View File
@@ -500,6 +500,16 @@ body {
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
.ps-command-group-title {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--ps-text-muted); opacity: 0.7;
padding: 10px 12px 4px;
}
.ps-command-item .ps-command-subtitle {
font-size: 12px; color: var(--ps-text-muted); opacity: 0.8;
margin-top: 2px;
}
.ps-command-item.is-active .ps-command-subtitle { color: var(--ps-accent-contrast); opacity: 0.9; }
/* ---- Settings ---- */
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }