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
@@ -0,0 +1,102 @@
/**
* ContextRail — the right-hand rail on the writing surface.
*
* Shows three things about the current chapter:
* - characters mentioned in this chapter (auto-derived from `@` mentions),
* - pinned notes (the author's quick-reference stack),
* - notes linked to this chapter (`linkedTo`).
*
* Each item opens its entity sheet when clicked.
*/
import React from "react";
import type { Chapter, Entity } from "@pubscript/core";
import { collectMentionsInDoc } from "@pubscript/core";
interface ContextRailProps {
chapter: Chapter | null;
entities: Entity[];
onOpenEntity: (id: string) => void;
}
/** True for a character entity (has `relationships`, no `fields`/`body`). */
function isCharacter(e: Entity): e is Extract<Entity, { relationships: unknown }> {
return !("fields" in e) && !("body" in e);
}
/** A short label for an entity in the rail, regardless of kind. */
function label(e: Entity): string {
if ("fields" in e) return e.name;
if ("body" in e) return e.body.trim().slice(0, 40) || "(untitled note)";
return e.name;
}
export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProps) {
const mentionedIds = chapter ? collectMentionsInDoc(chapter.doc) : new Set<string>();
// Characters mentioned in this chapter, in mention order, deduped.
const mentionedChars = React.useMemo(() => {
const seen = new Set<string>();
const out: Entity[] = [];
for (const id of mentionedIds) {
if (seen.has(id)) continue;
const e = entities.find((x) => x.id === id);
if (e && isCharacter(e) && !seen.has(id)) {
seen.add(id);
out.push(e);
}
}
return out;
}, [mentionedIds, entities]);
const pinnedNotes = React.useMemo(
() => entities.filter((e) => "body" in e && e.pinned),
[entities],
);
const relatedNotes = React.useMemo(
() => entities.filter((e) => "body" in e && e.linkedTo === chapter?.id),
[entities, chapter?.id],
);
const Section = ({
title,
items,
}: {
title: string;
items: Entity[];
}) => (
<section className="ps-context-section">
<h3 className="ps-context-section-title">
{title}
<span className="ps-context-count">{items.length}</span>
</h3>
{items.length === 0 ? (
<p className="ps-context-empty">—</p>
) : (
<ul className="ps-context-list">
{items.map((e) => (
<li key={e.id}>
<button
type="button"
className="ps-context-item"
onClick={() => onOpenEntity(e.id)}
>
<span className="ps-context-item-name">{label(e)}</span>
</button>
</li>
))}
</ul>
)}
</section>
);
return (
<aside className="ps-context-rail">
<Section title="In this chapter" items={mentionedChars} />
<Section title="Pinned" items={pinnedNotes} />
<Section title="Linked notes" items={relatedNotes} />
</aside>
);
}
export default ContextRail;
+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 "";
}
}
@@ -71,7 +71,7 @@ export function EntityEditor({
<div className="ps-entity-editor-body">
{isCharacterDraft ? (
<>
<EntityFields entity={draft} onChange={patch} />
<EntityFields entity={draft} onChange={patch} entities={entities} />
<div className="ps-relationship-section">
<h3>Relationships</h3>
<RelationshipEditor
@@ -85,7 +85,7 @@ export function EntityEditor({
</div>
</>
) : (
<EntityFields entity={draft} onChange={patch} />
<EntityFields entity={draft} onChange={patch} entities={entities} />
)}
</div>
@@ -12,6 +12,14 @@ import { ENTITY_FIELDS } from "@pubscript/core";
import { Input } from "@pubscript/ui";
import { Select } from "@pubscript/ui";
/** A short display label for an entity, regardless of kind. */
export function entityLabel(e: Entity): string {
if ("body" in e) {
return e.body.trim().slice(0, 40) || "(untitled note)";
}
return e.name;
}
/** 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) {
@@ -40,12 +48,15 @@ export function fieldsFor(e: Entity): FieldDef[] {
interface EntityFieldsProps {
entity: Entity;
onChange: (patch: Partial<Entity>) => void;
/** All entities in the book, used to resolve `link` field references. */
entities?: Entity[];
}
function renderField(
field: FieldDef,
value: string | string[],
onChange: (v: string | string[]) => void,
entities?: Entity[],
) {
const label = (
<label className="ps-field-label">
@@ -79,9 +90,22 @@ function renderField(
);
case "list":
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
case "link":
case "link": {
const opts = (entities ?? [])
.filter((e) => e.id !== value)
.map((e) => ({ value: e.id, label: entityLabel(e) }));
return (
<div>
{label}
<Select
value={typeof value === "string" ? value : ""}
options={[{ value: "", label: "— none —" }, ...opts]}
onChange={(ev) => onChange(ev.target.value)}
/>
</div>
);
}
case "text":
default:
return (
<div className="ps-field-span-2">
{label}
@@ -146,7 +170,7 @@ function TagField({
);
}
export function EntityFields({ entity, onChange }: EntityFieldsProps) {
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
const fields = fieldsFor(entity);
return (
@@ -157,6 +181,7 @@ export function EntityFields({ entity, onChange }: EntityFieldsProps) {
field,
getFieldValue(entity, field.key),
(v) => onChange({ [field.key]: v } as Partial<Entity>),
entities,
)}
</div>
))}
@@ -0,0 +1,106 @@
/**
* MentionPopover — a hover popover for `@` mention chips inside the editor.
*
* The editor renders each mention as `<span class="mention mention-character"
* data-id="...">`. This component delegates mouse events on the editor content
* container: hovering a chip looks up the entity and shows a small popover with
* a one-line preview and an "Open sheet" action. Clicking the chip itself is
* left to the editor (atoms are inert), so the only click target is the button.
*/
import React from "react";
import type { Entity } from "@pubscript/core";
interface MentionPopoverProps {
/** All entities, used to resolve a hovered chip's `data-id`. */
entities: Entity[];
/** Called when the user opens the sheet for a mention. */
onOpen: (id: string) => void;
/** Ref to the editor content container that hosts the mention chips. */
containerRef: React.RefObject<HTMLElement | null>;
}
/** A one-line preview for an entity, regardless of kind. */
function preview(e: Entity): string {
if ("fields" in e) {
const d = (e.fields.description ?? e.fields.bio ?? "") as string;
return d.replace(/\s+/g, " ").trim().slice(0, 120);
}
if ("body" in e) {
return e.body.replace(/\s+/g, " ").trim().slice(0, 120);
}
// character
return (e as { bio?: string }).bio?.replace(/\s+/g, " ").trim().slice(0, 120) ?? "";
}
/** A short display label for an entity, regardless of kind. */
function label(e: Entity): string {
if ("body" in e) {
return e.body.trim().slice(0, 40) || "(untitled note)";
}
return e.name;
}
export function MentionPopover({ entities, onOpen, containerRef }: MentionPopoverProps) {
const [state, setState] = React.useState<{
id: string;
x: number;
y: number;
} | null>(null);
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onOver = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null;
const chip = target?.closest?.("span.mention-character") as HTMLElement | null;
if (!chip) return;
const id = chip.getAttribute("data-id");
if (!id) return;
const rect = chip.getBoundingClientRect();
setState({ id, x: rect.left, y: rect.bottom + 6 });
};
const onOut = (ev: MouseEvent) => {
const related = ev.relatedTarget as HTMLElement | null;
if (related?.closest?.("span.mention-character")) return;
setState(null);
};
el.addEventListener("mouseover", onOver);
el.addEventListener("mouseout", onOut);
return () => {
el.removeEventListener("mouseover", onOver);
el.removeEventListener("mouseout", onOut);
};
}, [containerRef]);
if (!state) return null;
const entity = entities.find((e) => e.id === state.id) ?? null;
// Guard against hovering a chip whose entity no longer exists.
if (!entity) return null;
const style: React.CSSProperties = {
position: "fixed",
left: `${Math.min(state.x, window.innerWidth - 240)}px`,
top: `${Math.min(state.y, window.innerHeight - 120)}px`,
zIndex: 1000,
};
return (
<div className="ps-mention-popover" style={style}>
<div className="ps-mention-popover-name">{label(entity)}</div>
<div className="ps-mention-popover-preview">{preview(entity)}</div>
<button
type="button"
className="ps-mention-popover-open"
onClick={() => onOpen(entity.id)}
>
Open sheet
</button>
</div>
);
}
export default MentionPopover;