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:
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user