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
+54
View File
@@ -13,6 +13,7 @@ import type {
import type { StorageAdapter } from "./storage.js";
import { createUlid } from "./ids.js";
import { nowIso } from "./time.js";
import { fuzzySort } from "./fuzzy.js";
interface EntityRec {
key: string;
@@ -49,6 +50,35 @@ const entityKey = (projectId: string, kind: string, id: string): string =>
const kindOf = (e: Entity): string =>
"fields" in e ? "world" : "body" in e ? "note" : "character";
/**
* Concatenate every human-readable field of an entity into a single searchable
* string (name + field values). Used by the fuzzy search index.
*/
function entitySearchText(e: Entity): string {
if ("fields" in e) {
// WorldItem
const parts = [e.name, e.kind];
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
return parts.join(" ");
}
if ("body" in e) {
// Note
return [e.body, ...e.tags].join(" ");
}
// Character
return [
e.name,
e.role,
e.bio,
e.appearance,
e.voice,
e.relationships,
e.arc,
e.status,
...e.tags,
].join(" ");
}
/**
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
* Implements the exact `StorageAdapter` contract so the app can run fully
@@ -136,6 +166,30 @@ export const storageLocal: StorageAdapter = {
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
},
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
const rows = await db.entities
.where("projectId")
.equals(projectId)
.toArray();
const filtered = kind ? rows.filter((r) => r.kind === kind) : rows;
return filtered.map((r) => r.data);
},
async getEntity(projectId: string, kind: string, id: string): Promise<Entity | null> {
const row = await db.entities.get(entityKey(projectId, kind, id));
return row ? row.data : null;
},
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
await db.entities.delete(entityKey(projectId, kind, id));
},
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
const all = await this.listEntities(projectId);
if (!q) return all;
return fuzzySort(all, q, entitySearchText);
},
async saveBoard(b: Board): Promise<void> {
await db.boards.put(b);
},