feat(entity): Chunk #4 data layer + entity UI (characters & world-building)

Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
Schwitzz
2026-10-25 22:42:55 -05:00
parent 81a44b6e95
commit 4efa71515e
36 changed files with 2764 additions and 82 deletions
@@ -0,0 +1,109 @@
/**
* EntityEditor — the right-rail drawer for creating and editing an entity.
*
* Composes the generic `EntityFields` renderer plus, for characters, the
* `RelationshipEditor`. Emits the full entity on Save and deletes on demand.
*/
import React from "react";
import type { Character, Entity } from "@pubscript/core";
import { Button } from "@pubscript/ui";
import { EntityFields } from "./EntityFields.js";
import { RelationshipEditor } from "./RelationshipEditor.js";
import { RelationshipMap } from "./RelationshipMap.js";
interface EntityEditorProps {
entity: Entity | null;
entities: Entity[];
open: boolean;
onClose: () => void;
onSave: (entity: Entity) => void;
onDelete: (id: string) => void;
}
function isCharacter(e: Entity): e is Character {
return "relationships" in e;
}
export function EntityEditor({
entity,
entities,
open,
onClose,
onSave,
onDelete,
}: EntityEditorProps) {
const [draft, setDraft] = React.useState<Entity | null>(entity);
React.useEffect(() => {
setDraft(entity);
}, [entity]);
if (!open || !draft) return null;
const isExisting = entities.some((e) => e.id === draft.id);
const isCharacterDraft = isCharacter(draft);
const canDelete = isExisting && isCharacterDraft;
const title = isCharacterDraft
? (draft.name || "Edit character")
: "fields" in draft
? `Edit ${draft.kind}`
: "Edit note";
const patch = (p: Partial<Entity>) => {
setDraft({ ...draft, ...p });
};
const save = () => {
onSave(draft);
onClose();
};
return (
<aside className="ps-entity-editor">
<header className="ps-entity-editor-header">
<h2>{isExisting ? title : "New entity"}</h2>
<button type="button" className="ps-icon-btn" aria-label="Close" onClick={onClose}>
×
</button>
</header>
<div className="ps-entity-editor-body">
{isCharacterDraft ? (
<>
<EntityFields entity={draft} onChange={patch} />
<div className="ps-relationship-section">
<h3>Relationships</h3>
<RelationshipEditor
character={draft}
entities={entities}
onChange={(relationships) => patch({ relationships })}
/>
<div className="ps-relationship-map-wrap">
<RelationshipMap entities={entities} focusId={draft.id} />
</div>
</div>
</>
) : (
<EntityFields entity={draft} onChange={patch} />
)}
</div>
<footer className="ps-entity-editor-actions">
<div className="ps-spacer" />
{canDelete ? (
<Button
variant="danger"
onClick={() => onDelete(draft.id)}
>
Delete
</Button>
) : null}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={save}>Save</Button>
</footer>
</aside>
);
}