From 301e842c5c8ad8502b9da616606475bf69f80f72 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Mon, 26 Oct 2026 12:02:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(entity):=20Chunk=20#4=20remaining=20items?= =?UTF-8?q?=20=E2=80=94=20consistency=20UI,=20context=20rail,=20palette=20?= =?UTF-8?q?search,=20expanded=20seed=20&=20DATA=5FMODEL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/src/components/CommandPalette.tsx | 152 +++++++-- apps/web/src/components/WritingView.tsx | 15 + .../web/src/features/entities/ContextRail.tsx | 102 ++++++ .../src/features/entities/EntitiesView.tsx | 128 ++++++- .../src/features/entities/EntityEditor.tsx | 4 +- .../src/features/entities/EntityFields.tsx | 31 +- .../src/features/entities/MentionPopover.tsx | 106 ++++++ apps/web/src/store/seed.ts | 73 +++- apps/web/src/styles/entities.css | 239 +++++++++++++ docs/DATA_MODEL.md | 315 ++++++++++++++++++ packages/core/src/backlinks.ts | 7 + packages/core/src/consistency.test.ts | 59 +++- packages/core/src/consistency.ts | 74 ++-- packages/core/src/schema.ts | 3 +- packages/editor/src/mention.ts | 2 +- .../editor/src/nodes/CharacterMention.tsx | 10 +- packages/ui/src/AppShell.css | 10 + 17 files changed, 1265 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/features/entities/ContextRail.tsx create mode 100644 apps/web/src/features/entities/MentionPopover.tsx create mode 100644 docs/DATA_MODEL.md diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60b61fe..8bb294a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useProjectStore } from "../store/project-store.js"; import type { NavItem } from "./Sidebar.js"; +import type { Entity } from "@pubscript/core"; export interface CommandPaletteProps { open: boolean; @@ -8,11 +10,35 @@ export interface CommandPaletteProps { nav: NavItem[]; } +/** A short label for an entity in the palette. */ +function entityLabel(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; +} + +/** A short snippet for an entity in the palette. */ +function entitySnippet(e: Entity): string { + if ("fields" in e) { + const kind = e.kind; + const region = e.fields.region; + return kind === "location" ? `Location Β· ${Array.isArray(region) ? region[0] ?? "" : region ?? ""}` : kind; + } + if ("body" in e) return e.body.trim().slice(0, 60); + return e.role || e.status || "character"; +} + +type PaletteEntry = + | { kind: "command"; item: { id: string; label: string; icon: string; path: string; kbd: string } } + | { kind: "chapter"; item: { id: string; title: string } } + | { kind: "entity"; item: Entity }; + export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const navigate = useNavigate(); + const { entities, chapters } = useProjectStore(); const commands = useMemo( () => @@ -26,14 +52,47 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) { [nav], ); - const filtered = useMemo( - () => - query - ? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase())) - : commands, - [commands, query], + const entityResults = useMemo(() => { + if (!query.trim()) return []; + const q = query.toLowerCase(); + return entities.filter((e) => entityLabel(e).toLowerCase().includes(q)); + }, [query, entities]); + + const chapterResults = useMemo(() => { + if (!query.trim()) return []; + const q = query.toLowerCase(); + return chapters.filter((c) => c.title.toLowerCase().includes(q)); + }, [query, chapters]); + + const groups = useMemo(() => { + const sections: { title?: string; items: PaletteEntry[] }[] = []; + if (query.trim()) { + if (chapterResults.length) { + sections.push({ + title: "Chapters", + items: chapterResults.map((c) => ({ kind: "chapter", item: { id: c.id, title: c.title } })), + }); + } + if (entityResults.length) { + sections.push({ title: "Entities", items: entityResults.map((e) => ({ kind: "entity", item: e })) }); + } + } + sections.push({ + items: commands.map((c) => ({ kind: "command" as const, item: c })), + }); + return sections; + }, [query, chapterResults, entityResults, commands]); + + // Flatten for arrow-key navigation. + const flat = useMemo( + () => groups.flatMap((g) => g.items), + [groups], ); + useEffect(() => { + setActive(0); + }, [query]); + useEffect(() => { if (open) { setQuery(""); @@ -50,20 +109,23 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) { onClose(); } else if (e.key === "ArrowDown") { e.preventDefault(); - setActive((a) => Math.min(a + 1, filtered.length - 1)); + setActive((a) => Math.min(a + 1, flat.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (flat[active]) run((flat[active] as { kind: "command"; item: { id: string } }).item); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); - }, [open, filtered, onClose]); + }, [open, flat, active, onClose]); if (!open) return null; - const run = (id: string) => { - navigate(id); + const run = (item: { id: string }) => { + navigate(item.id); onClose(); }; @@ -78,7 +140,7 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) { { setQuery(e.target.value); @@ -86,20 +148,64 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) { }} />
- {filtered.length === 0 ? ( + {flat.length === 0 ? (
No matches
) : ( - filtered.map((c, i) => ( - + groups.map((g, gi) => ( +
+ {g.title ? ( +
{g.title}
+ ) : null} + {g.items.map((entry) => { + const idx = flat.indexOf(entry); + if (entry.kind === "command") { + const c = entry.item; + return ( + + ); + } + if (entry.kind === "chapter") { + const ch = entry.item; + return ( + + ); + } + const e = entry.item; + return ( + + ); + })} +
)) )}
diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx index 124cdeb..acc2d72 100644 --- a/apps/web/src/components/WritingView.tsx +++ b/apps/web/src/components/WritingView.tsx @@ -8,10 +8,13 @@ * - shows word count, scene count, and an autosave indicator. */ import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor"; import type { DocumentRef, AutosaveState } from "@pubscript/core"; import { useProjectStore } from "../store/project-store.js"; import { useSettings } from "../store/settings.js"; +import { ContextRail } from "../features/entities/ContextRail.js"; +import { MentionPopover } from "../features/entities/MentionPopover.js"; /** Block-level tags that can contain the cursor and be highlighted in focus mode. */ const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]); @@ -49,6 +52,7 @@ interface WritingViewProps { } export function WritingView({ onChapterChange }: WritingViewProps) { + const navigate = useNavigate(); const project = useProjectStore((s) => s.project); const chapters = useProjectStore((s) => s.chapters); const activeChapterId = useProjectStore((s) => s.activeChapterId); @@ -56,6 +60,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) { const scheduleSave = useProjectStore((s) => s.scheduleSave); const flushAutosave = useProjectStore((s) => s.flushAutosave); const autosave = useProjectStore((s) => s.autosave); + const entities = useProjectStore((s) => s.entities); const focusMode = useSettings((st) => st.focusMode); const typewriterMode = useSettings((st) => st.typewriterMode); @@ -230,7 +235,17 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
+ navigate(`/characters?entity=${encodeURIComponent(id)}`)} + />
+ navigate(`/characters?entity=${encodeURIComponent(id)}`)} + />
diff --git a/apps/web/src/features/entities/ContextRail.tsx b/apps/web/src/features/entities/ContextRail.tsx new file mode 100644 index 0000000..dbd3155 --- /dev/null +++ b/apps/web/src/features/entities/ContextRail.tsx @@ -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 { + 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(); + + // Characters mentioned in this chapter, in mention order, deduped. + const mentionedChars = React.useMemo(() => { + const seen = new Set(); + 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[]; + }) => ( +
+

+ {title} + {items.length} +

+ {items.length === 0 ? ( +

β€”

+ ) : ( +
    + {items.map((e) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + + return ( + + ); +} + +export default ContextRail; diff --git a/apps/web/src/features/entities/EntitiesView.tsx b/apps/web/src/features/entities/EntitiesView.tsx index b8fff4c..69aaf64 100644 --- a/apps/web/src/features/entities/EntitiesView.tsx +++ b/apps/web/src/features/entities/EntitiesView.tsx @@ -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(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=`). + 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() { />
- {visible.length === 0 ? ( + {tab === "consistency" ? ( + { + const e = entities.find((x) => x.id === id); + if (e) { setDraft(e); setOpen(true); } + }} /> + ) : visible.length === 0 ? (

No entities match.

) : (
@@ -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 ( +
+
βœ“
+

No inconsistencies found. Your entity registry and chapters are consistent.

+
+ ); + } + + return ( +
+
+ {issues.length} issue{issues.length === 1 ? "" : "s"} +
+
    + {issues.map((issue, i) => ( +
  • + {iconFor(issue)} +
    +
    {titleFor(issue)}
    +
    {detailFor(issue)}
    +
    + {(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && ( + + )} +
  • + ))} +
+
+ ); +} + +/** 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 ""; + } +} diff --git a/apps/web/src/features/entities/EntityEditor.tsx b/apps/web/src/features/entities/EntityEditor.tsx index 26544aa..13254d1 100644 --- a/apps/web/src/features/entities/EntityEditor.tsx +++ b/apps/web/src/features/entities/EntityEditor.tsx @@ -71,7 +71,7 @@ export function EntityEditor({
{isCharacterDraft ? ( <> - +

Relationships

) : ( - + )}
diff --git a/apps/web/src/features/entities/EntityFields.tsx b/apps/web/src/features/entities/EntityFields.tsx index a9646d4..f65cccb 100644 --- a/apps/web/src/features/entities/EntityFields.tsx +++ b/apps/web/src/features/entities/EntityFields.tsx @@ -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) => 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 = (