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
+129 -23
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useProjectStore } from "../store/project-store.js";
import type { NavItem } from "./Sidebar.js"; import type { NavItem } from "./Sidebar.js";
import type { Entity } from "@pubscript/core";
export interface CommandPaletteProps { export interface CommandPaletteProps {
open: boolean; open: boolean;
@@ -8,11 +10,35 @@ export interface CommandPaletteProps {
nav: NavItem[]; 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) { export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { entities, chapters } = useProjectStore();
const commands = useMemo( const commands = useMemo(
() => () =>
@@ -26,14 +52,47 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
[nav], [nav],
); );
const filtered = useMemo( const entityResults = useMemo(() => {
() => if (!query.trim()) return [];
query const q = query.toLowerCase();
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase())) return entities.filter((e) => entityLabel(e).toLowerCase().includes(q));
: commands, }, [query, entities]);
[commands, query],
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(() => { useEffect(() => {
if (open) { if (open) {
setQuery(""); setQuery("");
@@ -50,20 +109,23 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
onClose(); onClose();
} else if (e.key === "ArrowDown") { } else if (e.key === "ArrowDown") {
e.preventDefault(); 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") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setActive((a) => Math.max(a - 1, 0)); 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); document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, filtered, onClose]); }, [open, flat, active, onClose]);
if (!open) return null; if (!open) return null;
const run = (id: string) => { const run = (item: { id: string }) => {
navigate(id); navigate(item.id);
onClose(); onClose();
}; };
@@ -78,7 +140,7 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
<input <input
ref={inputRef} ref={inputRef}
className="ps-command-input" className="ps-command-input"
placeholder="Type a command or search…" placeholder="Search chapters, characters, notes…"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
@@ -86,20 +148,64 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
}} }}
/> />
<div className="ps-command-list"> <div className="ps-command-list">
{filtered.length === 0 ? ( {flat.length === 0 ? (
<div className="ps-command-empty">No matches</div> <div className="ps-command-empty">No matches</div>
) : ( ) : (
filtered.map((c, i) => ( groups.map((g, gi) => (
<button <div key={gi}>
key={c.id} {g.title ? (
className={`ps-command-item${i === active ? " is-active" : ""}`} <div className="ps-command-group-title">{g.title}</div>
onClick={() => run(c.id)} ) : null}
onMouseEnter={() => setActive(i)} {g.items.map((entry) => {
> const idx = flat.indexOf(entry);
<span className="ps-command-icon">{c.icon}</span> if (entry.kind === "command") {
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span> const c = entry.item;
<span className="ps-command-kbd">{c.path}</span> return (
</button> <button
key={c.id}
className={`ps-command-item${idx === active ? " is-active" : ""}`}
onClick={() => run(c)}
onMouseEnter={() => setActive(idx)}
>
<span className="ps-command-icon">{c.icon}</span>
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
<span className="ps-command-kbd">{c.path}</span>
</button>
);
}
if (entry.kind === "chapter") {
const ch = entry.item;
return (
<button
key={ch.id}
className={`ps-command-item${idx === active ? " is-active" : ""}`}
onClick={() => run({ id: `/` })}
onMouseEnter={() => setActive(idx)}
>
<span className="ps-command-icon">📖</span>
<span style={{ flex: 1, textAlign: "left" }}>{ch.title}</span>
</button>
);
}
const e = entry.item;
return (
<button
key={e.id}
className={`ps-command-item${idx === active ? " is-active" : ""}`}
onClick={() => run({ id: `/characters?entity=${encodeURIComponent(e.id)}` })}
onMouseEnter={() => setActive(idx)}
>
<span className="ps-command-icon">
{"fields" in e ? "🌍" : "body" in e ? "📝" : "👤"}
</span>
<span style={{ flex: 1, textAlign: "left" }}>
<div>{entityLabel(e)}</div>
<div className="ps-command-subtitle">{entitySnippet(e)}</div>
</span>
</button>
);
})}
</div>
)) ))
)} )}
</div> </div>
+15
View File
@@ -8,10 +8,13 @@
* - shows word count, scene count, and an autosave indicator. * - shows word count, scene count, and an autosave indicator.
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor"; import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
import type { DocumentRef, AutosaveState } from "@pubscript/core"; import type { DocumentRef, AutosaveState } from "@pubscript/core";
import { useProjectStore } from "../store/project-store.js"; import { useProjectStore } from "../store/project-store.js";
import { useSettings } from "../store/settings.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. */ /** 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"]); const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]);
@@ -49,6 +52,7 @@ interface WritingViewProps {
} }
export function WritingView({ onChapterChange }: WritingViewProps) { export function WritingView({ onChapterChange }: WritingViewProps) {
const navigate = useNavigate();
const project = useProjectStore((s) => s.project); const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters); const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId); const activeChapterId = useProjectStore((s) => s.activeChapterId);
@@ -56,6 +60,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const scheduleSave = useProjectStore((s) => s.scheduleSave); const scheduleSave = useProjectStore((s) => s.scheduleSave);
const flushAutosave = useProjectStore((s) => s.flushAutosave); const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave); const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities);
const focusMode = useSettings((st) => st.focusMode); const focusMode = useSettings((st) => st.focusMode);
const typewriterMode = useSettings((st) => st.typewriterMode); const typewriterMode = useSettings((st) => st.typewriterMode);
@@ -230,7 +235,17 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}> <div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}>
<div className="ps-editor" ref={editorDomRef}> <div className="ps-editor" ref={editorDomRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
<MentionPopover
entities={entities}
containerRef={editorDomRef}
onOpen={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
</div> </div>
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
</div> </div>
<div className="ps-writing-status"> <div className="ps-writing-status">
@@ -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. * kind. Characters show a relationship map in the drawer.
*/ */
import React from "react"; import React from "react";
import type { Entity } from "@pubscript/core"; import { useNavigate, useSearchParams } from "react-router-dom";
import { fuzzySort } from "@pubscript/core"; import type { ConsistencyIssue, Entity } from "@pubscript/core";
import { fuzzySort, findInconsistencies } from "@pubscript/core";
import { Button, Input, Select } from "@pubscript/ui"; import { Button, Input, Select } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js"; import { useProjectStore } from "../../store/project-store.js";
import { EntityEditor } from "./EntityEditor.js"; import { EntityEditor } from "./EntityEditor.js";
import { entityLabel } from "./RelationshipEditor.js"; import { entityLabel } from "./RelationshipEditor.js";
type TabId = "all" | "people" | "places" | "notes"; type TabId = "all" | "people" | "places" | "notes" | "consistency";
interface Tab { interface Tab {
id: TabId; id: TabId;
label: string; label: string;
/** Filter predicate over entities, or null for "all". */ /** Filter predicate over entities, or null for "all". */
match: (e: Entity) => boolean; match: (e: Entity) => boolean;
/** When true, the tab shows a special view instead of entity cards. */
special?: boolean;
} }
const TABS: Tab[] = [ const TABS: Tab[] = [
@@ -27,6 +30,7 @@ const TABS: Tab[] = [
{ id: "people", label: "People", match: (e) => "relationships" in e }, { id: "people", label: "People", match: (e) => "relationships" in e },
{ id: "places", label: "Places", match: (e) => "fields" in e }, { id: "places", label: "Places", match: (e) => "fields" in e },
{ id: "notes", label: "Notes", match: (e) => "body" in e }, { id: "notes", label: "Notes", match: (e) => "body" in e },
{ id: "consistency", label: "Consistency", match: () => true, special: true },
]; ];
const KIND_OPTIONS = [ const KIND_OPTIONS = [
@@ -49,6 +53,8 @@ function searchText(e: Entity): string {
} }
export function EntitiesView() { export function EntitiesView() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const entities = useProjectStore((s) => s.entities); const entities = useProjectStore((s) => s.entities);
const createEntity = useProjectStore((s) => s.createEntity); const createEntity = useProjectStore((s) => s.createEntity);
const updateEntity = useProjectStore((s) => s.updateEntity); const updateEntity = useProjectStore((s) => s.updateEntity);
@@ -60,6 +66,24 @@ export function EntitiesView() {
const [draft, setDraft] = React.useState<Entity | null>(null); const [draft, setDraft] = React.useState<Entity | null>(null);
const [open, setOpen] = React.useState(false); 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 visible = React.useMemo(() => {
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e)); const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered; return query ? fuzzySort(filtered, query, searchText) : filtered;
@@ -121,7 +145,12 @@ export function EntitiesView() {
/> />
</div> </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> <p className="ps-muted">No entities match.</p>
) : ( ) : (
<div className="ps-entity-grid"> <div className="ps-entity-grid">
@@ -184,3 +213,94 @@ function tagsFor(e: Entity): string[] {
if ("body" in e) return e.tags; if ("body" in e) return e.tags;
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"> <div className="ps-entity-editor-body">
{isCharacterDraft ? ( {isCharacterDraft ? (
<> <>
<EntityFields entity={draft} onChange={patch} /> <EntityFields entity={draft} onChange={patch} entities={entities} />
<div className="ps-relationship-section"> <div className="ps-relationship-section">
<h3>Relationships</h3> <h3>Relationships</h3>
<RelationshipEditor <RelationshipEditor
@@ -85,7 +85,7 @@ export function EntityEditor({
</div> </div>
</> </>
) : ( ) : (
<EntityFields entity={draft} onChange={patch} /> <EntityFields entity={draft} onChange={patch} entities={entities} />
)} )}
</div> </div>
@@ -12,6 +12,14 @@ import { ENTITY_FIELDS } from "@pubscript/core";
import { Input } from "@pubscript/ui"; import { Input } from "@pubscript/ui";
import { Select } 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. */ /** Read a field value from an entity, regardless of where it is stored. */
export function getFieldValue(e: Entity, key: string): string | string[] { export function getFieldValue(e: Entity, key: string): string | string[] {
if ("fields" in e) { if ("fields" in e) {
@@ -40,12 +48,15 @@ export function fieldsFor(e: Entity): FieldDef[] {
interface EntityFieldsProps { interface EntityFieldsProps {
entity: Entity; entity: Entity;
onChange: (patch: Partial<Entity>) => void; onChange: (patch: Partial<Entity>) => void;
/** All entities in the book, used to resolve `link` field references. */
entities?: Entity[];
} }
function renderField( function renderField(
field: FieldDef, field: FieldDef,
value: string | string[], value: string | string[],
onChange: (v: string | string[]) => void, onChange: (v: string | string[]) => void,
entities?: Entity[],
) { ) {
const label = ( const label = (
<label className="ps-field-label"> <label className="ps-field-label">
@@ -79,9 +90,22 @@ function renderField(
); );
case "list": case "list":
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />; 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": case "text":
default:
return ( return (
<div className="ps-field-span-2"> <div className="ps-field-span-2">
{label} {label}
@@ -146,7 +170,7 @@ function TagField({
); );
} }
export function EntityFields({ entity, onChange }: EntityFieldsProps) { export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
const fields = fieldsFor(entity); const fields = fieldsFor(entity);
return ( return (
@@ -157,6 +181,7 @@ export function EntityFields({ entity, onChange }: EntityFieldsProps) {
field, field,
getFieldValue(entity, field.key), getFieldValue(entity, field.key),
(v) => onChange({ [field.key]: v } as Partial<Entity>), (v) => onChange({ [field.key]: v } as Partial<Entity>),
entities,
)} )}
</div> </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;
+69 -4
View File
@@ -10,6 +10,7 @@ import {
createUlid, createUlid,
type Board, type Board,
type Character, type Character,
type Note,
type ProseMirrorDoc, type ProseMirrorDoc,
type WorldItem, type WorldItem,
type StorageAdapter, type StorageAdapter,
@@ -20,12 +21,29 @@ function para(text: string): ProseMirrorDoc["content"][0] {
return { type: "paragraph", content: [{ type: "text", text }] }; return { type: "paragraph", content: [{ type: "text", text }] };
} }
/** Build a ProseMirror doc from an array of paragraph texts. */ /** A paragraph containing a character mention atom (used to exercise backlinks & consistency). */
function doc(paras: string[]): ProseMirrorDoc { function paraMention(
return { type: "doc", content: paras.map(para) }; before: string,
entityId: string,
label: string,
after: string,
): ProseMirrorDoc["content"][0] {
return {
type: "paragraph",
content: [
{ type: "text", text: before },
{ type: "CharacterMention", attrs: { entityId, label } },
{ type: "text", text: after },
],
};
} }
const CHAPTERS: { title: string; paras: string[] }[] = [ /** Build a ProseMirror doc from an array of paragraph texts or nodes. */
function doc(paras: Array<string | ProseMirrorDoc["content"][0]>): ProseMirrorDoc {
return { type: "doc", content: paras.map((p) => (typeof p === "string" ? para(p) : p)) };
}
const CHAPTERS: { title: string; paras: Array<string | ProseMirrorDoc["content"][0]> }[] = [
{ {
title: "Chapter One — The Arrival", title: "Chapter One — The Arrival",
paras: [ paras: [
@@ -55,6 +73,12 @@ const CHAPTERS: { title: string; paras: string[] }[] = [
"Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.", "Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.",
"This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.", "This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.",
"She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.", "She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.",
paraMention(
"Her father, ",
"char-elias-vell",
"Elias Vell",
" had drowned keeping the light when she was fifteen — and now the duty he left behind felt heavier than the whole sea.",
),
"When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.", "When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.",
'"I can teach you," he said. "If you want it. No one has ever wanted it before."', '"I can teach you," he said. "If you want it. No one has ever wanted it before."',
"Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.", "Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.",
@@ -128,6 +152,21 @@ const CHARACTERS: Character[] = [
status: "active", status: "active",
tags: ["mysterious", "hermit"], tags: ["mysterious", "hermit"],
}, },
{
id: "char-elias-vell",
projectId: "",
name: "Elias Vell",
role: "The Keeper Who Died",
bio: "Mara's father and the lighthouse keeper before Tomas. Drowned keeping the light through the Great Storm of Mara's fifteenth year. He is gone, but his absence shapes everyone who loved him — especially the daughter who left and came back.",
appearance: "Salt of Mara's own hair, a keeper's sun-leathered face, hands gone blue from the deep glass.",
voice: "Never heard. Remembered for the songs he hummed while turning the lamp.",
relationships: [
{ targetId: "char-mara-vell", kind: "family", note: "Daughter; the one he could not save from the harbor, or from himself." },
],
arc: "A grief inherited, not chosen — the weight Mara carries and must finally set down.",
status: "dead",
tags: ["ghost", "father", "keeper"],
},
]; ];
const WORLD: WorldItem = { const WORLD: WorldItem = {
@@ -146,6 +185,30 @@ const WORLD: WorldItem = {
}, },
}; };
const LIGHTHOUSE: WorldItem = {
id: "world-lighthouse",
projectId: "",
kind: "location",
name: "The Lighthouse",
fields: {
kind: "location",
description:
"A tall white tower on the northern point of the rock, reached by one hundred and fourteen slick steps. Its lamp is fed by shards of living glass, and the mechanism that turns the beam has seized more than once.",
population: "—",
economy: "—",
notable: "The Lamp (where the beam is kindled) · The Gears (that seize with salt and rust) · The Stairs (one hundred and fourteen, and counting).",
},
};
const ARRIVAL_NOTE: Note = {
id: "note-arrival",
projectId: "",
body: "Mara Vell returns to the Glass Harbor three years after fleeing it. She came for a reason she has never named — and the harbor, its failing light, and the people who keep it are not the same as the memory she carried away. What is the grief she runs from, and why does the glass answer when she says she wants the light?",
pinned: true,
tags: ["plot", "act-one"],
linkedTo: "char-mara-vell",
};
const BOARD: Board = { const BOARD: Board = {
id: "board-story", id: "board-story",
projectId: "", projectId: "",
@@ -193,6 +256,8 @@ export async function seedDemoProject(adapter: StorageAdapter): Promise<string>
await adapter.saveEntity({ ...character, projectId: project.id }); await adapter.saveEntity({ ...character, projectId: project.id });
} }
await adapter.saveEntity({ ...WORLD, projectId: project.id }); await adapter.saveEntity({ ...WORLD, projectId: project.id });
await adapter.saveEntity({ ...LIGHTHOUSE, projectId: project.id });
await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id });
await adapter.saveBoard({ ...BOARD, projectId: project.id }); await adapter.saveBoard({ ...BOARD, projectId: project.id });
return project.id; return project.id;
+239
View File
@@ -464,3 +464,242 @@
fill: var(--ps-text-muted); fill: var(--ps-text-muted);
pointer-events: none; pointer-events: none;
} }
/* ---- Context rail (writing surface) ---- */
.ps-context-rail {
flex: 0 0 260px;
overflow-y: auto;
border-left: 1px solid var(--ps-border);
padding: 16px 14px;
background: var(--ps-surface);
font-family: var(--ps-font-ui);
}
.ps-context-section {
margin-bottom: 22px;
}
.ps-context-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ps-text-muted);
margin: 0 0 8px;
}
.ps-context-count {
font-size: 11px;
font-weight: 600;
color: var(--ps-text-muted);
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: 999px;
padding: 0 7px;
min-width: 20px;
text-align: center;
}
.ps-context-empty {
margin: 0;
font-size: 13px;
color: var(--ps-text-muted);
opacity: 0.7;
}
.ps-context-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ps-context-item {
display: block;
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 8px;
padding: 7px 10px;
background: var(--ps-surface-2);
font-family: var(--ps-font-ui);
font-size: 13px;
color: var(--ps-text);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.ps-context-item:hover {
border-color: var(--ps-accent);
background: color-mix(in srgb, var(--ps-accent) 12%, transparent);
}
.ps-context-item-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Mention hover popover (writing surface) ---- */
.ps-mention-popover {
position: fixed;
z-index: 1000;
width: 240px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: 10px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
padding: 12px 14px;
font-family: var(--ps-font-ui);
pointer-events: auto;
}
.ps-mention-popover-name {
font-size: 15px;
font-weight: 600;
color: var(--ps-text);
margin: 0 0 4px;
}
.ps-mention-popover-preview {
font-size: 13px;
color: var(--ps-text-muted);
margin: 0 0 10px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ps-mention-popover-open {
display: block;
width: 100%;
text-align: left;
border: none;
border-radius: 7px;
padding: 6px 10px;
background: var(--ps-accent);
color: #fff;
font-size: 13px;
font-weight: 600;
font-family: var(--ps-font-ui);
cursor: pointer;
}
.ps-mention-popover-open:hover {
filter: brightness(1.05);
}
/* ---- Consistency panel ---- */
.ps-consistency-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 64px 24px;
gap: 12px;
}
.ps-consistency-empty-icon {
font-size: 40px;
line-height: 1;
color: var(--ps-success);
}
.ps-consistency-panel {
border: 1px solid var(--ps-border);
border-radius: 12px;
overflow: hidden;
background: var(--ps-surface);
}
.ps-consistency-header {
padding: 14px 18px;
border-bottom: 1px solid var(--ps-border);
background: var(--ps-surface-alt);
}
.ps-consistency-count {
font-weight: 700;
font-size: 14px;
color: var(--ps-text);
}
.ps-consistency-count:empty {
color: var(--ps-success);
}
.ps-consistency-list {
list-style: none;
margin: 0;
padding: 0;
}
.ps-consistency-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--ps-border);
}
.ps-consistency-item:last-child {
border-bottom: none;
}
.ps-consistency-icon {
font-size: 18px;
line-height: 1.3;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.ps-consistency-item:not(:last-child) .ps-consistency-icon {
color: var(--ps-warning);
}
.ps-consistency-body {
flex: 1;
min-width: 0;
}
.ps-consistency-title {
font-size: 14px;
font-weight: 600;
color: var(--ps-text);
}
.ps-consistency-detail {
font-size: 13px;
color: var(--ps-text-muted);
margin-top: 3px;
line-height: 1.4;
}
.ps-consistency-open {
flex-shrink: 0;
border: 1px solid var(--ps-border);
border-radius: 6px;
background: transparent;
color: var(--ps-text);
padding: 5px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: var(--ps-font-ui);
}
.ps-consistency-open:hover {
border-color: var(--ps-accent);
color: var(--ps-accent);
}
+315
View File
@@ -0,0 +1,315 @@
# PubScript Data Model
The canonical reference for the PubScript data model. Every type lives in
`packages/core/src/types.ts`; the field registry lives in
`packages/core/src/schema.ts`; the persistence contract lives in
`packages/core/src/storage.ts`. Code against these, never against a backend.
- IDs are **ULIDs** (sortable, collision-free) — see `packages/core/src/ids.ts`.
- Timestamps are **UTC ISO-8601** strings — see `packages/core/src/time.ts`.
- Documents are stored as **ProseMirror JSON** (the TipTap schema).
---
## 1. The project tree
A project is the top-level container. Everything below it carries a
`projectId`.
```
Project
├── Book[] ordered collection of chapters
│ └── Chapter[] a ProseMirror doc + word count / scene map
├── Entity[] characters, world items, notes (the entity engine)
├── Board a freeform node/edge canvas (plot, timeline)
└── AISession[] AI conversations + inline suggestions
```
| Type | Key fields | Notes |
| --- | --- | --- |
| `Project` | `id, projectId, title, ownerId, sharedWith[], settings, cover, createdAt, updatedAt` | `sharedWith` is `{ userId, perm }`; `perm` is `read` \| `edit`. |
| `Book` | `id, projectId, title, order, chapters[]` | `chapters` are materialized; the store also keeps chapters by `doc` for autosave. |
| `Chapter` | `id, bookId, projectId, title, doc, rev, order, meta` | `rev` is an optimistic-concurrency counter; `meta` holds `wordCount`, `scenes`, `updatedAt`. |
| `DocumentRef` | `id, projectId, bookId?, title, doc, rev` | The save shape handed to `adapter.saveDocument`. |
`ChapterMeta.scenes` are scene-break positions computed from the doc; a scene
break is a `sceneBreak` node in the ProseMirror tree (Chunk 3).
---
## 2. The entity model
The entity engine (Chunk 4) is the heart of PubScript's "story brain." An
`Entity` is a discriminated union of three kinds:
```ts
type Entity = Character | WorldItem | Note;
```
Entities are identified by the *absence* of `fields`/`body` rather than an
explicit `kind` tag:
- **Character** — no `fields`, no `body`. Has `name`, `role`, `bio`, `appearance`,
`voice`, `relationships`, `arc`, `status`, `tags`.
- **WorldItem** — has a `kind` (`location` \| `faction` \| `item` \| `glossary`
\| `system`) and a free-form `fields` record.
- **Note** — has a `body`, `pinned`, `tags`, and an optional `linkedTo`.
### 2.1 Character
```ts
interface Character {
id: string;
projectId: string;
name: string;
role: string; // protagonist / antagonist / supporting …
bio: string;
appearance: string;
voice: string;
relationships: Array<{
targetId: string; // id of another Character
kind: RelationshipKind;
note: string;
}>;
arc: string;
status: string; // active / inactive / dead / unseen
tags: string[];
}
```
`status: "dead"` is meaningful: the consistency checker flags a dead character
who is still mentioned (by entity id) in a chapter body — a dead character
should not appear alive in the story.
### 2.2 WorldItem
```ts
interface WorldItem {
id: string;
projectId: string;
kind: WorldItemKind; // location | faction | item | glossary | system
name: string;
fields: Record<string, string | string[]>;
}
```
`fields` is free-form and driven by the field registry (`ENTITY_FIELDS`); a
`list` field (e.g. a location's `keyPeople`) stores a `string[]`.
### 2.3 Note
```ts
interface Note {
id: string;
projectId: string;
body: string;
pinned: boolean;
tags: string[];
linkedTo?: string; // id of the entity this note annotates
}
```
---
## 3. The field registry (`ENTITY_FIELDS`)
`packages/core/src/schema.ts` is the single source of truth that drives both the
generic entity editor and the server's validation. Adding a field to a kind here
makes it appear in the UI with no per-entity code.
```ts
type FieldType = "text" | "textarea" | "select" | "list" | "link";
interface FieldDef {
key: string;
label: string;
type: FieldType;
options?: string[]; // required for `select` and `link`
hint?: string;
}
```
| Kind | Fields |
| --- | --- |
| `character` | name, role (select), status (select), **home (link → location)**, bio, appearance, voice, arc, tags (list) |
| `location` | name, region, climate, keyPeople (list), description |
| `faction` | name, goals, rivals (list), allies (list), description |
| `item` | name, origin, powers (list), holder |
| `glossary` | name (Term), definition |
| `system` | name, rules, limitations |
| `note` | body, tags (list) |
The `link` field type renders an entity-reference selector; its `hint` records
the target kind (e.g. "linked location"). `ENTITY_KINDS` lists every kind in
display order.
---
## 4. Relationships
Characters relate to one another through `relationships`. Each entry names a
`targetId` (another character's id), a `kind`, and a free-form `note`.
```ts
type RelationshipKind =
| "ally" | "rival" | "family" | "sibling" | "friend"
| "spouse" | "partner" | "mentor" | "romantic" | "custom";
```
Relationship kinds split into two classes for the consistency checker:
- **Symmetric** — should hold in both directions: `sibling`, `friend`, `spouse`,
`partner`, `family`, `rival`. If Ada lists "Bram: sibling", Bram should list
"Ada: sibling" too.
- **Directed** — one-directional is fine: `ally`, `mentor`, `romantic`, `custom`.
`SYMMETRIC_RELATIONSHIPS` (in `consistency.ts`) is the source of truth for this
split.
---
## 5. Consistency checks (`consistency.ts`)
`findInconsistencies(chapters, entities)` runs three checks and returns a
`ConsistencyIssue[]`:
| Issue kind | Detector | Meaning |
| --- | --- | --- |
| `orphan-mention` | `findOrphanedMentions` | A chapter mentions an entity id that no longer exists (the entity was deleted). |
| `relationship-asymmetry` | `findRelationshipAsymmetry` | A symmetric relationship recorded by one side but not reciprocated. |
| `dead-character-mentioned` | `findDeadCharacterMentions` | A `dead` character is still mentioned (by entity id) in a chapter body. |
Bare-name mentions (no `entityId`) are ignored by the dead-character check — a
reference to a name that is not yet an entity is an orphan-mention concern, not a
dead-character one.
---
## 6. Mentions & backlinks (`backlinks.ts`)
A **mention** is an inline atom node (`CharacterMention`) that links prose to a
character entity. In the ProseMirror JSON it is:
```json
{ "type": "CharacterMention", "attrs": { "entityId": "char-…", "name": "Mara" } }
```
The node renders inline in HTML as
`<span class="mention mention-character" data-id="…" data-label="…">@Name</span>`.
The in-memory attribute is `entityId` (what the core reads); the DOM keeps
`data-id` so the hover popover can resolve the entity on mouseover.
Helpers:
- `collectMentionsInDoc(doc)` → `Set<entityId>` — the IDs mentioned in one doc.
- `collectMentionedEntityIds(chapters)` → `Set<entityId>` — across many docs.
- `findMentionsInDoc(doc, entityIds, entityNames)` → node paths where an entity is referenced.
- `backlinksForEntity(chapters, entityId)` → `Backlink[]` — every chapter that
references the entity, with the node paths where it appears.
- `docHasMentions(doc)` → `boolean`.
Backlinks power the context rail (showing where an entity is referenced) and the
mention hover card.
---
## 7. The board (`Board`)
A freeform canvas of nodes and edges, used for plotting and timelines.
```ts
interface FlowNode {
id: string;
type: "character" | "world" | "chapter" | "note" | "image" | "group";
position: { x: number; y: number };
data: Record<string, unknown>;
}
interface FlowEdge {
id: string;
source: string;
target: string;
type: "leads-to" | "contrasts" | "causes" | "custom";
label?: string;
}
interface Board { id; projectId; nodes: FlowNode[]; edges: FlowEdge[]; }
```
---
## 8. Persistence: the StorageAdapter contract
`packages/core/src/storage.ts` defines the seam that makes web / desktop / local
modes interchangeable. Every feature codes against this contract, never against
`ServerAdapter` or `LocalAdapter` directly. A single contract-test suite
(`contract` script) enforces parity across implementations.
```ts
interface StorageAdapter {
// projects
listProjects(): Promise<ProjectMeta[]>;
createProject(p: NewProject): Promise<Project>;
getProject(id: string): Promise<Project>;
// books & chapters
createBook(b: { projectId; title }): Promise<Book>;
listBooks(projectId): Promise<Book[]>;
listChapters(bookId): Promise<Chapter[]>;
// content (debounced autosave)
saveDocument(doc: DocumentRef): Promise<void>;
getChapter(id): Promise<Chapter | null>;
// entities (the Chunk 4 engine)
saveEntity(e: Entity): Promise<void>;
listEntities(projectId, kind?): Promise<Entity[]>; // kind: "character" | "world" | "note"
getEntity(projectId, kind, id): Promise<Entity | null>;
deleteEntity(projectId, kind, id): Promise<void>;
searchEntities(projectId, q): Promise<Entity[]>; // fuzzy over name + fields
// board
saveBoard(b: Board): Promise<void>;
saveAISession(s: AISession): Promise<void>;
// live updates
onEvent(cb: (e: StoreEvent) => void): () => void;
}
```
`listEntities` filters by a coarse `kind` (`"character"`, `"world"`, `"note"`);
`searchEntities` does a fuzzy match over an entity's name and field values,
ordered best-first (empty `q` returns all).
`StoreEvent` fans live updates to subscribed clients: `doc-changed`,
`entity-changed`, `board-changed`, `ai-session-changed`, `presence`, `conflict`.
### Implementations
- **Local** (`@pubscript/core/local`) — IndexedDB via Dexie, used by the offline
browser app.
- **Server** (`ServerAdapter`) — Fastify + SQLite, used in multi-tenant mode.
Both must pass the identical contract suite.
---
## 9. AI suggestions
```ts
type SuggestionKind = "replace" | "insert" | "delete" | "entity-update" | "draft";
type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
interface SuggestionTarget {
docId: string;
range?: { nodeId: string }; // a stable node anchor, not a raw offset
nodePath?: number[];
}
interface Suggestion {
id; sessionId;
target: SuggestionTarget;
before; after; rationale; kind; status;
}
```
A suggestion targets a **stable node anchor** (`range.nodeId`), not a raw offset,
so it survives edits — see PLAN.md §5.5.
+7
View File
@@ -55,6 +55,13 @@ export function collectMentionedEntityIds(
return ids; return ids;
} }
/** Collect the set of entity IDs mentioned in a single document. */
export function collectMentionsInDoc(doc: ProseMirrorDoc): Set<string> {
const ids = new Set<string>();
walkForIds(doc, (entityId) => ids.add(entityId));
return ids;
}
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void { function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
const visit = (content: unknown[]) => { const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) { for (const node of content as Record<string, unknown>[]) {
+56 -3
View File
@@ -3,9 +3,11 @@ import {
findOrphanedMentions, findOrphanedMentions,
parseRelationships, parseRelationships,
findRelationshipAsymmetry, findRelationshipAsymmetry,
findDeadCharacterMentions,
findInconsistencies, findInconsistencies,
SYMMETRIC_RELATIONSHIPS, SYMMETRIC_RELATIONSHIPS,
} from "./consistency.js"; } from "./consistency.js";
import { collectMentionsInDoc } from "./backlinks.js";
import type { Character, Entity, ProseMirrorDoc } from "./types.js"; import type { Character, Entity, ProseMirrorDoc } from "./types.js";
const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({ const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
@@ -152,12 +154,32 @@ describe("findRelationshipAsymmetry", () => {
}); });
describe("findInconsistencies", () => { describe("findInconsistencies", () => {
it("combines orphan-mention and relationship-asymmetry issues", () => { it("combines orphan-mention, relationship-asymmetry, and dead-character issues", () => {
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }]; // Chapter mentions "ghost" (orphan) and "ada" (dead character still alive in prose).
const chapters = [
{
id: "ch1",
title: "Ch 1",
doc: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Ada met " },
{ type: "CharacterMention", attrs: { entityId: "ada", name: "Ada" } },
{ type: "text", text: " and a " },
{ type: "CharacterMention", attrs: { entityId: "ghost", name: "ghost" } },
],
},
],
} as ProseMirrorDoc,
},
];
const ada: Character = { const ada: Character = {
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "", id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
relationships: [{ targetId: "maria", kind: "sibling", note: "" }], relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
arc: "", status: "active", tags: [], arc: "", status: "dead", tags: [],
}; };
const maria: Character = { const maria: Character = {
id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "", id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
@@ -167,5 +189,36 @@ describe("findInconsistencies", () => {
const issues = findInconsistencies(chapters, entities); const issues = findInconsistencies(chapters, entities);
expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true); expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true);
expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true); expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true);
expect(issues.some((i) => i.kind === "dead-character-mentioned")).toBe(true);
});
it("does not flag a dead character who is not mentioned in any chapter", () => {
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
const ada: Character = {
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
arc: "", status: "dead", tags: [],
relationships: [],
};
const issues = findDeadCharacterMentions(chapters, [ada]);
expect(issues).toHaveLength(0);
});
it("collects every entity id referenced in a doc", () => {
const docWithTwo = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Ada and " },
{ type: "CharacterMention", attrs: { entityId: "maria", name: "Maria" } },
{ type: "text", text: " met " },
{ type: "CharacterMention", attrs: { entityId: "brad", name: "Brad" } },
],
},
],
} as ProseMirrorDoc;
const ids = collectMentionsInDoc(docWithTwo);
expect([...ids].sort()).toEqual(["brad", "maria"]);
}); });
}); });
+55 -19
View File
@@ -5,6 +5,7 @@
* See CHUNK #4 step 2.4. * See CHUNK #4 step 2.4.
*/ */
import type { Character, Entity, ProseMirrorDoc } from "./types.js"; import type { Character, Entity, ProseMirrorDoc } from "./types.js";
import { collectMentionsInDoc } from "./backlinks.js";
export type RelationshipSymmetry = "symmetric" | "directed"; export type RelationshipSymmetry = "symmetric" | "directed";
@@ -32,7 +33,16 @@ export interface RelationshipIssue {
relationship: string; 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. * Find mention nodes that reference an entity id that no longer exists (e.g.
@@ -44,25 +54,50 @@ export function findOrphanedMentions(
): MentionIssue[] { ): MentionIssue[] {
const issues: MentionIssue[] = []; const issues: MentionIssue[] = [];
for (const ch of chapters) { for (const ch of chapters) {
const visit = (content: unknown[], _path: number[]): void => { const mentioned = collectMentionsInDoc(ch.doc);
for (const node of content as Record<string, unknown>[]) { for (const entityId of mentioned) {
if (!node || typeof node !== "object") continue; if (!entityIds.has(entityId)) {
if (node.type === "CharacterMention") { issues.push({
const attrs = node.attrs as { entityId?: string; name?: string } | undefined; kind: "orphan-mention",
if (attrs?.entityId && !entityIds.has(attrs.entityId)) { chapterId: ch.id,
issues.push({ chapterTitle: ch.title,
kind: "orphan-mention", entityId,
chapterId: ch.id, });
chapterTitle: ch.title,
entityId: attrs.entityId,
name: attrs.name,
});
}
}
if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
} }
}; }
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; return issues;
} }
@@ -125,6 +160,7 @@ export function findInconsistencies(
const issues: ConsistencyIssue[] = [ const issues: ConsistencyIssue[] = [
...findOrphanedMentions(chapters, entityIds), ...findOrphanedMentions(chapters, entityIds),
...findRelationshipAsymmetry(entities), ...findRelationshipAsymmetry(entities),
...findDeadCharacterMentions(chapters, entities),
]; ];
return issues; return issues;
} }
+2 -1
View File
@@ -29,11 +29,12 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
fields: [ fields: [
{ key: "name", label: "Name", type: "text" }, { key: "name", label: "Name", type: "text" },
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] }, { key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "home", label: "Home", type: "link", hint: "linked location" },
{ key: "bio", label: "Bio", type: "textarea" }, { key: "bio", label: "Bio", type: "textarea" },
{ key: "appearance", label: "Appearance", type: "textarea" }, { key: "appearance", label: "Appearance", type: "textarea" },
{ key: "voice", label: "Voice", type: "textarea" }, { key: "voice", label: "Voice", type: "textarea" },
{ key: "arc", label: "Arc", type: "textarea" }, { key: "arc", label: "Arc", type: "textarea" },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "tags", label: "Tags", type: "list" }, { key: "tags", label: "Tags", type: "list" },
], ],
}, },
+1 -1
View File
@@ -103,7 +103,7 @@ export const MentionAutocomplete = Extension.create({
const nodeType = schema.nodes[CHARACTER_MENTION]; const nodeType = schema.nodes[CHARACTER_MENTION];
if (!nodeType) return false; if (!nodeType) return false;
const mention = nodeType.create({ id, label }); const mention = nodeType.create({ entityId: id, label });
const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention); const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention);
editor.view.dispatch(tr); editor.view.dispatch(tr);
return true; return true;
@@ -3,7 +3,7 @@ import { CHARACTER_MENTION, colorAttribute } from "../constants.js";
/** /**
* Character mention — an inline atom linking to a character entity. * Character mention — an inline atom linking to a character entity.
* Attributes: `id` (character id) and `label` (display name). Inline in HTML * Attributes: `entityId` (character id) and `label` (display name). Inline in HTML
* as `<span class="mention mention-character" data-id="...">@Name</span>`. * as `<span class="mention mention-character" data-id="...">@Name</span>`.
*/ */
export const CharacterMention = Node.create({ export const CharacterMention = Node.create({
@@ -14,11 +14,11 @@ export const CharacterMention = Node.create({
addAttributes() { addAttributes() {
return { return {
id: { entityId: {
default: null, default: null,
isRequired: true, isRequired: true,
parseHTML: (element) => element.getAttribute("data-id"), parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => ({ "data-id": attributes.id }), renderHTML: (attributes) => ({ "data-id": attributes.entityId }),
}, },
label: { label: {
default: "", default: "",
@@ -36,9 +36,9 @@ export const CharacterMention = Node.create({
renderHTML({ node }) { renderHTML({ node }) {
return [ return [
"span", "span",
{ class: "mention mention-character", "data-id": node.attrs.id, "data-label": node.attrs.label }, { class: "mention mention-character", "data-id": node.attrs.entityId, "data-label": node.attrs.label },
"@", "@",
node.attrs.label ?? node.attrs.id, node.attrs.label ?? node.attrs.entityId,
]; ];
}, },
}); });
+10
View File
@@ -500,6 +500,16 @@ body {
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; } .ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); } .ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); } .ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
.ps-command-group-title {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--ps-text-muted); opacity: 0.7;
padding: 10px 12px 4px;
}
.ps-command-item .ps-command-subtitle {
font-size: 12px; color: var(--ps-text-muted); opacity: 0.8;
margin-top: 2px;
}
.ps-command-item.is-active .ps-command-subtitle { color: var(--ps-accent-contrast); opacity: 0.9; }
/* ---- Settings ---- */ /* ---- Settings ---- */
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; } .ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }