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
+124 -4
View File
@@ -6,20 +6,23 @@
* 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 { useNavigate, useSearchParams } from "react-router-dom";
import type { ConsistencyIssue, Entity } from "@pubscript/core";
import { fuzzySort, findInconsistencies } 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";
type TabId = "all" | "people" | "places" | "notes" | "consistency";
interface Tab {
id: TabId;
label: string;
/** Filter predicate over entities, or null for "all". */
match: (e: Entity) => boolean;
/** When true, the tab shows a special view instead of entity cards. */
special?: boolean;
}
const TABS: Tab[] = [
@@ -27,6 +30,7 @@ const TABS: Tab[] = [
{ 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 },
{ id: "consistency", label: "Consistency", match: () => true, special: true },
];
const KIND_OPTIONS = [
@@ -49,6 +53,8 @@ function searchText(e: Entity): string {
}
export function EntitiesView() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const entities = useProjectStore((s) => s.entities);
const createEntity = useProjectStore((s) => s.createEntity);
const updateEntity = useProjectStore((s) => s.updateEntity);
@@ -60,6 +66,24 @@ export function EntitiesView() {
const [draft, setDraft] = React.useState<Entity | null>(null);
const [open, setOpen] = React.useState(false);
// Run every consistency check over the current entities and chapters.
const chapters = useProjectStore((s) => s.chapters);
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
// Open a specific entity when linked from elsewhere (e.g. the writing-view
// context rail: `/characters?entity=<id>`).
React.useEffect(() => {
const id = searchParams.get("entity");
if (!id) return;
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
// Clear the param so a reload doesn't re-open the drawer.
navigate("/characters", { replace: true });
}, [searchParams, entities, navigate]);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
@@ -121,7 +145,12 @@ export function EntitiesView() {
/>
</div>
{visible.length === 0 ? (
{tab === "consistency" ? (
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) { setDraft(e); setOpen(true); }
}} />
) : visible.length === 0 ? (
<p className="ps-muted">No entities match.</p>
) : (
<div className="ps-entity-grid">
@@ -184,3 +213,94 @@ function tagsFor(e: Entity): string[] {
if ("body" in e) return e.tags;
return e.tags;
}
interface ConsistencyPanelProps {
issues: ConsistencyIssue[];
onOpenEntity: (id: string) => void;
}
/**
* Consistency tab — shows the combined output of `findInconsistencies()` as a
* list of warnings. Each issue names the affected entity, which can be opened
* in the editor drawer.
*/
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
if (issues.length === 0) {
return (
<div className="ps-consistency-empty">
<div className="ps-consistency-empty-icon">✓</div>
<p className="ps-muted">No inconsistencies found. Your entity registry and chapters are consistent.</p>
</div>
);
}
return (
<div className="ps-consistency-panel">
<div className="ps-consistency-header">
<span className="ps-consistency-count">{issues.length} issue{issues.length === 1 ? "" : "s"}</span>
</div>
<ul className="ps-consistency-list">
{issues.map((issue, i) => (
<li key={`${issue.kind}-${i}`} className="ps-consistency-item">
<span className="ps-consistency-icon">{iconFor(issue)}</span>
<div className="ps-consistency-body">
<div className="ps-consistency-title">{titleFor(issue)}</div>
<div className="ps-consistency-detail">{detailFor(issue)}</div>
</div>
{(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && (
<button
type="button"
className="ps-consistency-open"
onClick={() => onOpenEntity(issue.entityId)}
>
Open
</button>
)}
</li>
))}
</ul>
</div>
);
}
/** Severity icon for an issue. */
function iconFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
return "⚠";
case "dead-character-mentioned":
return "💀";
case "relationship-asymmetry":
return "↔";
default:
return "•";
}
}
/** Primary label for an issue. */
function titleFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
return `Orphaned mention in “${issue.chapterTitle}”`;
case "dead-character-mentioned":
return `Dead character mentioned in “${issue.chapterTitle}”`;
case "relationship-asymmetry":
return `Unreciprocated ${issue.relationship} relationship`;
default:
return "Consistency issue";
}
}
/** Secondary explanation for an issue. */
function detailFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
return `A mention references an entity that no longer exists.`;
case "dead-character-mentioned":
return `${issue.name ?? "A character"} is marked dead but still appears in the chapter.`;
case "relationship-asymmetry":
return `${issue.fromName} lists ${issue.toName} as “${issue.relationship}”, but the feeling isn't recorded on the other side.`;
default:
return "";
}
}