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,165 @@
/**
* EntityFields — generic field renderer for the entity editor.
*
* Renders the fields defined in the core `ENTITY_FIELDS` registry for the
* entity's kind. Field values live at the top level for characters/notes and
* inside the `fields` record for world items; `getFieldValue`/`setFieldValue`
* abstract that away so the renderer is kind-agnostic.
*/
import React from "react";
import type { Entity, FieldDef } from "@pubscript/core";
import { ENTITY_FIELDS } from "@pubscript/core";
import { Input } from "@pubscript/ui";
import { Select } from "@pubscript/ui";
/** 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) {
const v = e.fields[key];
return typeof v === "string" ? v : "";
}
const v = (e as unknown as Record<string, unknown>)[key];
if (Array.isArray(v)) return v;
return typeof v === "string" ? v : "";
}
/** Return a copy of `e` with `key` set to `value`. */
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
if ("fields" in e) {
return { ...e, fields: { ...e.fields, [key]: value } };
}
return { ...e, [key]: value };
}
/** The field definitions for an entity's kind. */
export function fieldsFor(e: Entity): FieldDef[] {
const kind = "fields" in e ? e.kind : "body" in e ? "note" : "character";
return ENTITY_FIELDS[kind]?.fields ?? [];
}
interface EntityFieldsProps {
entity: Entity;
onChange: (patch: Partial<Entity>) => void;
}
function renderField(
field: FieldDef,
value: string | string[],
onChange: (v: string | string[]) => void,
) {
const label = (
<label className="ps-field-label">
{field.label}
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
</label>
);
switch (field.type) {
case "textarea":
return (
<div className="ps-field-span-2">
{label}
<textarea
className="ps-textarea ps-input"
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
/>
</div>
);
case "select":
return (
<div>
{label}
<Select
value={typeof value === "string" ? value : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
onChange={(ev) => onChange(ev.target.value)}
/>
</div>
);
case "list":
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
case "link":
case "text":
default:
return (
<div className="ps-field-span-2">
{label}
<Input
className="ps-input"
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
/>
</div>
);
}
}
/** A small inline tag editor (add on Enter, remove on ×). */
function TagField({
value,
onChange,
}: {
value: string[];
onChange: (v: string[]) => void;
}) {
const [draft, setDraft] = React.useState("");
const add = () => {
const t = draft.trim();
if (t && !value.includes(t)) {
onChange([...value, t]);
}
setDraft("");
};
return (
<div className="ps-tag-field">
<div className="ps-tag-list">
{value.map((t, i) => (
<span key={`${t}-${i}`} className="ps-tag-chip">
{t}
<button
type="button"
className="ps-tag-remove"
aria-label={`Remove ${t}`}
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
>
×
</button>
</span>
))}
</div>
<input
className="ps-input"
value={draft}
placeholder="Add tag + Enter"
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
add();
}
}}
/>
</div>
);
}
export function EntityFields({ entity, onChange }: EntityFieldsProps) {
const fields = fieldsFor(entity);
return (
<div className="ps-entity-fields">
{fields.map((field) => (
<div key={field.key} className="ps-field">
{renderField(
field,
getFieldValue(entity, field.key),
(v) => onChange({ [field.key]: v } as Partial<Entity>),
)}
</div>
))}
</div>
);
}