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,186 @@
/**
* EntitiesView — the entity engine directory (Chunk #4).
*
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
* opens the create/edit drawer. A "New" control creates an entity of a chosen
* 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 { 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";
interface Tab {
id: TabId;
label: string;
/** Filter predicate over entities, or null for "all". */
match: (e: Entity) => boolean;
}
const TABS: Tab[] = [
{ id: "all", label: "All", match: () => true },
{ 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 },
];
const KIND_OPTIONS = [
{ value: "character", label: "Character" },
{ value: "world", label: "Place" },
{ value: "note", label: "Note" },
];
/** Human-readable text used for fuzzy matching. */
function searchText(e: Entity): string {
if ("fields" in e) {
return [e.name, e.kind, ...Object.values(e.fields).filter((v) => typeof v === "string")].join(" ");
}
if ("body" in e) {
return [e.body, ...e.tags].join(" ");
}
const c = e;
const rel = (c.relationships ?? []).map((r) => r.kind).join(" ");
return [c.name, c.role, c.bio, c.appearance, c.voice, c.arc, c.status, rel, ...c.tags].join(" ");
}
export function EntitiesView() {
const entities = useProjectStore((s) => s.entities);
const createEntity = useProjectStore((s) => s.createEntity);
const updateEntity = useProjectStore((s) => s.updateEntity);
const deleteEntity = useProjectStore((s) => s.deleteEntity);
const [tab, setTab] = React.useState<TabId>("all");
const [query, setQuery] = React.useState("");
const [newKind, setNewKind] = React.useState("character");
const [draft, setDraft] = React.useState<Entity | null>(null);
const [open, setOpen] = React.useState(false);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
}, [entities, tab, query]);
const openNew = async () => {
const e = await createEntity(newKind);
setDraft(e);
setOpen(true);
};
const openEntity = (e: Entity) => {
setDraft(e);
setOpen(true);
};
const save = (e: Entity) => {
void updateEntity(e);
};
const remove = async (id: string) => {
await deleteEntity(id);
setOpen(false);
setDraft(null);
};
return (
<div className="ps-entities-view">
<div className="ps-entity-toolbar">
<div className="ps-entity-tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="ps-entity-new-group">
<Select
className="ps-entity-new-kind"
value={newKind}
options={KIND_OPTIONS}
onChange={(ev) => setNewKind(ev.target.value)}
/>
<Button onClick={openNew}>+ New</Button>
</div>
</div>
<div className="ps-entity-search">
<Input
className="ps-entity-search-input"
placeholder="Search entities…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
{visible.length === 0 ? (
<p className="ps-muted">No entities match.</p>
) : (
<div className="ps-entity-grid">
{visible.map((e) => (
<button
key={e.id}
type="button"
className="ps-entity-card"
onClick={() => openEntity(e)}
>
<div className="ps-entity-card-top">
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
</div>
<div className="ps-entity-card-name">{entityLabel(e)}</div>
<div className="ps-entity-card-sub">{subFor(e)}</div>
{tagsFor(e).length > 0 && (
<div className="ps-entity-card-tags">
{tagsFor(e).slice(0, 4).map((t) => (
<span key={t} className="ps-entity-tag">
{t}
</span>
))}
</div>
)}
</button>
))}
</div>
)}
<EntityEditor
entity={draft}
entities={entities}
open={open}
onClose={() => setOpen(false)}
onSave={save}
onDelete={remove}
/>
</div>
);
}
/** Short badge label for a card. */
function badgeFor(e: Entity): string {
if ("fields" in e) return e.kind;
if ("body" in e) return "note";
return "person";
}
/** Secondary line shown under the name. */
function subFor(e: Entity): string {
if ("fields" in e) return e.kind;
if ("body" in e) return e.body.slice(0, 80) || "Note";
const c = e;
return c.role || c.status || "";
}
/** Tags for a card. */
function tagsFor(e: Entity): string[] {
if ("fields" in e) return [];
if ("body" in e) return e.tags;
return e.tags;
}