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
+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;
}