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
+1 -1
View File
@@ -13,7 +13,7 @@ import { Admin } from "./routes/Admin.js";
const NAV = [
{ to: "/", label: "Write", icon: "✎", end: true },
{ to: "/characters", label: "Characters", icon: "👤", end: false },
{ to: "/characters", label: "Entities", icon: "👥", end: false },
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
{ to: "/ai", label: "AI", icon: "✨", end: false },
+71 -3
View File
@@ -1,4 +1,4 @@
import { useSettings, type Density } from "../store/settings.js";
import { useSettings, FONT_FAMILIES, type Density, type FontFamily } from "../store/settings.js";
import { Select, Button, toast } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
@@ -46,16 +46,56 @@ export function Settings() {
]}
/>
</div>
</div>
<div className="ps-settings-group">
<h3>Typography</h3>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Font</div>
<div className="ps-settings-desc">Body typeface for the writing column.</div>
</div>
<Select
value={s.fontFamily}
onChange={(e) => set({ fontFamily: e.target.value as FontFamily })}
options={FONT_FAMILIES.map((f) => ({ value: f.value, label: f.label }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Font size</div>
<div className="ps-settings-desc">Base UI font size in pixels.</div>
<div className="ps-settings-desc">Body text size in the writing column.</div>
</div>
<Select
value={String(s.fontSize)}
onChange={(e) => set({ fontSize: Number(e.target.value) })}
options={[13, 14, 15, 16, 17, 18].map((n) => ({ value: String(n), label: `${n}px` }))}
options={[14, 15, 16, 17, 18, 19, 20, 21, 22].map((n) => ({ value: String(n), label: `${n}px` }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Line spacing</div>
<div className="ps-settings-desc">Leading between body lines.</div>
</div>
<Select
value={String(s.leading)}
onChange={(e) => set({ leading: Number(e.target.value) })}
options={[1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0].map((n) => ({ value: String(n), label: n.toFixed(1) }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Column width</div>
<div className="ps-settings-desc">Max width of the writing column.</div>
</div>
<Select
value={String(s.measure)}
onChange={(e) => set({ measure: Number(e.target.value) })}
options={[600, 660, 720, 780, 840].map((n) => ({ value: String(n), label: `${n}px` }))}
/>
</div>
</div>
@@ -102,6 +142,34 @@ export function Settings() {
/>
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Focus mode</div>
<div className="ps-settings-desc">Dim everything but the block under the cursor. Toggle with F2.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.focusMode}
onChange={(e) => set({ focusMode: e.target.checked })}
/>
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Typewriter mode</div>
<div className="ps-settings-desc">Keep the active block centered as you type. Toggle with ⌘⇧T.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.typewriterMode}
onChange={(e) => set({ typewriterMode: e.target.checked })}
/>
</label>
</div>
</div>
<div className="ps-settings-row">
+104 -3
View File
@@ -7,10 +7,14 @@
* - debounces every edit through the storage adapter via `scheduleSave`,
* - shows word count, scene count, and an autosave indicator.
*/
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
import type { DocumentRef, AutosaveState } from "@pubscript/core";
import { useProjectStore } from "../store/project-store.js";
import { useSettings } from "../store/settings.js";
/** 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"]);
/** Count words in the editor document. */
function countWords(editor: Editor | null): number {
@@ -53,6 +57,12 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
const focusMode = useSettings((st) => st.focusMode);
const typewriterMode = useSettings((st) => st.typewriterMode);
const setSettings = useSettings((st) => st.set);
const editorDomRef = useRef<HTMLDivElement | null>(null);
const chapter = chapters.find((c) => c.id === activeChapterId) ?? null;
const projectId = project?.id ?? null;
@@ -83,6 +93,75 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
editor.commands.focus();
}, [editor, chapter?.id]);
// Focus mode: dim every block except the one holding the cursor. Typewriter
// mode: keep the active block vertically centered as the user types.
useEffect(() => {
if (!editor) return;
const editorDom = editorDomRef.current;
if (!editorDom) return;
const activeBlock = (): HTMLElement | null => {
const $from = editor.state.selection.$from;
const dom = editor.view.nodeDOM($from.pos) as HTMLElement | null;
let el: HTMLElement | null = dom;
while (el && editorDom.contains(el)) {
if (BLOCK_TAGS.has(el.tagName)) return el;
el = el.parentElement;
}
return null;
};
if (focusMode) {
const apply = () => {
const block = activeBlock();
const blocks = editorDom.querySelectorAll<HTMLElement>("p, h1, h2, h3, blockquote, pre, li, div");
blocks.forEach((b) => b.classList.toggle("ps-focus-active", b === block));
};
editor.on("selectionUpdate", apply);
editor.on("update", apply);
apply();
return () => {
editor.off("selectionUpdate", apply);
editor.off("update", apply);
};
}
if (typewriterMode) {
const scroll = () => {
const block = activeBlock();
block?.scrollIntoView({ block: "center" });
};
editor.on("selectionUpdate", scroll);
editor.on("update", scroll);
scroll();
return () => {
editor.off("selectionUpdate", scroll);
editor.off("update", scroll);
};
}
return undefined;
}, [editor, focusMode, typewriterMode]);
// Keyboard shortcuts: F2 toggles focus mode, Ctrl/Cmd+Shift+T toggles
// typewriter mode. Only active while editing inside the writing surface.
useEffect(() => {
const dom = editorDomRef.current;
if (!dom) return;
const onKey = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey;
if (e.key === "F2") {
e.preventDefault();
setSettings({ focusMode: !focusMode });
} else if (isMod && (e.key === "t" || e.key === "T") && e.shiftKey) {
e.preventDefault();
setSettings({ typewriterMode: !typewriterMode });
}
};
dom.addEventListener("keydown", onKey);
return () => dom.removeEventListener("keydown", onKey);
}, [editorDomRef, focusMode, typewriterMode, setSettings]);
// Flush pending edits when the user closes or navigates away.
useEffect(() => {
const onBeforeUnload = () => flushAutosave();
@@ -120,14 +199,36 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
))}
</select>
</label>
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool${focusMode ? " is-active" : ""}`}
title="Focus mode (F2)"
aria-pressed={focusMode}
onClick={() => setSettings({ focusMode: !focusMode })}
>
Focus
</button>
<button
type="button"
className={`ps-tool${typewriterMode ? " is-active" : ""}`}
title="Typewriter mode (⌘⇧T)"
aria-pressed={typewriterMode}
onClick={() => setSettings({ typewriterMode: !typewriterMode })}
>
Typewriter
</button>
</div>
<div className="ps-autosave-indicator" data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}>
<span className="ps-autosave-dot" />
<span>{autosaveLabel(autosave)}</span>
</div>
</div>
<div className="ps-content ps-editor-writing">
<div className="ps-editor">
<div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}>
<div className="ps-editor" ref={editorDomRef}>
<EditorContent editor={editor} />
</div>
</div>
@@ -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;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,135 @@
/**
* RelationshipEditor — editor for a character's relationships.
*
* Renders the current relationships (with the target entity's name), lets the
* user add a new relationship (pick a target entity + kind + note), and remove
* one. Emits the new relationships array via `onChange`.
*/
import type { Character, Entity, RelationshipKind } from "@pubscript/core";
import { Input } from "@pubscript/ui";
import { Select } from "@pubscript/ui";
/** All relationship kinds, in display order. */
export const RELATIONSHIP_KINDS: RelationshipKind[] = [
"ally",
"rival",
"family",
"sibling",
"friend",
"spouse",
"partner",
"mentor",
"romantic",
"custom",
];
const KIND_LABEL: Record<RelationshipKind, string> = {
ally: "Ally",
rival: "Rival",
family: "Family",
sibling: "Sibling",
friend: "Friend",
spouse: "Spouse",
partner: "Partner",
mentor: "Mentor",
romantic: "Romantic",
custom: "Custom",
};
interface RelationshipEditorProps {
character: Character;
/** All entities available as relationship targets. */
entities: Entity[];
onChange: (relationships: Character["relationships"]) => void;
}
export function RelationshipEditor({
character,
entities,
onChange,
}: RelationshipEditorProps) {
const targets = entities.filter((e) => e.id !== character.id);
const targetName = (id: string) => {
const e = entities.find((e) => e.id === id);
return e ? entityLabel(e) : "(unknown)";
};
const remove = (targetId: string) => {
onChange(character.relationships.filter((r) => r.targetId !== targetId));
};
return (
<div className="ps-relationship-editor">
<div className="ps-relationship-list">
{character.relationships.length === 0 && (
<p className="ps-relationship-empty">
No relationships yet. Add one below to start mapping this character's
connections.
</p>
)}
{character.relationships.map((r, i) => (
<div key={`${r.targetId}-${i}`} className="ps-relationship-item">
<span className="ps-relationship-kind">{KIND_LABEL[r.kind] ?? r.kind}</span>
<span className="ps-relationship-target">{targetName(r.targetId)}</span>
{r.note ? <span className="ps-relationship-note">{r.note}</span> : null}
<button
type="button"
className="ps-relationship-remove"
aria-label="Remove relationship"
onClick={() => remove(r.targetId)}
>
×
</button>
</div>
))}
</div>
<div className="ps-relationship-add">
<Select
className="ps-relationship-target-select"
value=""
options={[
{ value: "", label: "Target…" },
...targets.map((e) => ({ value: e.id, label: entityLabel(e) })),
]}
onChange={(ev) => {
const targetId = ev.target.value;
onChange([...character.relationships, { targetId, kind: "ally", note: "" }]);
}}
/>
<Select
className="ps-relationship-kind-select"
value="ally"
options={RELATIONSHIP_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] }))}
onChange={(ev) => {
const kind = ev.target.value as RelationshipKind;
const next = character.relationships.slice();
const last = next[next.length - 1];
if (last) last.kind = kind;
onChange(next);
}}
/>
<Input
className="ps-relationship-note"
placeholder="Note (optional)"
value={character.relationships[character.relationships.length - 1]?.note ?? ""}
onChange={(ev) => {
const note = ev.target.value;
const next = character.relationships.slice();
const last = next[next.length - 1];
if (last) last.note = note;
onChange(next);
}}
/>
</div>
</div>
);
}
/** Human label for an entity used as a relationship target. */
export function entityLabel(e: Entity): string {
if ("fields" in e) return `${e.name} (${e.kind})`;
if ("body" in e) return e.body.slice(0, 40) || "Note";
return e.name;
}
@@ -0,0 +1,167 @@
/**
* RelationshipMap — a lightweight force-directed SVG graph of a character's
* relationships.
*
* Pure SVG, no external graphing dependency. A small simulation runs once when
* the node set changes to compute stable positions; edges are drawn as lines
* and nodes as labelled circles. Non-character entities are ignored.
*/
import type { Character, Entity } from "@pubscript/core";
interface Node {
id: string;
label: string;
color: string;
x: number;
y: number;
vx: number;
vy: number;
}
const WIDTH = 520;
const HEIGHT = 320;
const CENTER = { x: WIDTH / 2, y: HEIGHT / 2 };
/** Extract the characters from a list of entities. */
function characters(entities: Entity[]): Character[] {
return entities.filter((e): e is Character => "relationships" in e && e.name.length > 0);
}
/** Human label for a node in the graph. */
function nodeLabel(e: Entity): string {
if ("fields" in e) return e.name;
if ("body" in e) return e.body.slice(0, 12) || "Note";
return e.name;
}
/** Run a simple force simulation for a fixed number of steps to lay out nodes. */
function layout(nodes: Node[], iterations = 240): Node[] {
const REPULSION = 2600;
const CENTER_FORCE = 0.004;
const DAMPING = 0.85;
const PAD = 40;
for (let iter = 0; iter < iterations; iter++) {
for (const a of nodes) {
a.vx = 0;
a.vy = 0;
}
// Pairwise repulsion.
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i]!;
const b = nodes[j]!;
let dx = a.x - b.x;
let dy = a.y - b.y;
let dist2 = dx * dx + dy * dy;
if (dist2 < 1) {
dx = (Math.random() - 0.5) * 2;
dy = (Math.random() - 0.5) * 2;
dist2 = dx * dx + dy * dy || 1;
}
const force = REPULSION / dist2;
const dist = Math.sqrt(dist2);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
// Center gravity.
for (const n of nodes) {
n.vx += (CENTER.x - n.x) * CENTER_FORCE;
n.vy += (CENTER.y - n.y) * CENTER_FORCE;
}
// Integrate + clamp to padding.
for (const n of nodes) {
n.vx *= DAMPING;
n.vy *= DAMPING;
n.x += n.vx;
n.y += n.vy;
n.x = Math.max(PAD, Math.min(WIDTH - PAD, n.x));
n.y = Math.max(PAD, Math.min(HEIGHT - PAD, n.y));
}
}
return nodes;
}
const CHARACTER_COLOR = "#8b5cf6";
const RELATED_COLOR = "#38bdf8";
interface RelationshipMapProps {
entities: Entity[];
/** The character whose relationships are centered in the graph. */
focusId?: string;
}
export function RelationshipMap({ entities, focusId }: RelationshipMapProps) {
const chars = characters(entities);
// Build the node/edge set. Start from the focus character (or the first
// character) and include everyone it is directly related to.
const focus = focusId ? chars.find((c) => c.id === focusId) : chars[0];
const focusChars = focus ? [focus] : chars;
const nodeIds = new Set<string>();
const edges: Array<[string, string]> = [];
for (const c of focusChars) {
nodeIds.add(c.id);
for (const r of c.relationships) {
nodeIds.add(r.targetId);
edges.push([c.id, r.targetId]);
}
}
// Seed positions on a circle, then simulate.
const seeded: Node[] = [...nodeIds].map((id, i) => {
const angle = (2 * Math.PI * i) / Math.max(1, nodeIds.size);
const label = nodeLabel(entities.find((e) => e.id === id)!) || id.slice(0, 6);
return {
id,
label,
color: id === focus?.id ? CHARACTER_COLOR : RELATED_COLOR,
x: CENTER.x + Math.cos(angle) * 110,
y: CENTER.y + Math.sin(angle) * 70,
vx: 0,
vy: 0,
};
});
const laidOut = layout(seeded);
const pos = new Map(laidOut.map((n) => [n.id, n]));
if (laidOut.length === 0) {
return (
<div className="ps-relationship-map-empty">
{focusChars.length === 0
? "No characters yet."
: "Select a character to map its relationships."}
</div>
);
}
return (
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="ps-relationship-svg" role="img" aria-label="Relationship map">
<g>
{edges.map(([from, to], i) => {
const a = pos.get(from);
const b = pos.get(to);
if (!a || !b) return null;
return <line key={`e-${i}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} className="ps-rel-edge" />;
})}
</g>
<g>
{laidOut.map((n) => (
<g key={n.id}>
<circle cx={n.x} cy={n.y} r={14} fill={n.color} className="ps-rel-node" />
<text x={n.x} y={n.y + 3} textAnchor="middle" className="ps-rel-node-label">
{n.label.length > 8 ? n.label.slice(0, 7) + "…" : n.label}
</text>
</g>
))}
</g>
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Entities feature barrel — Chunk #4 entity engine UI.
*
* Exposes the app-facing `EntitiesView` (the `/entities` route) and the
* composable pieces it's built from, so other routes (e.g. the editor mention
* integration) can reuse the editor and picker without importing internals.
*/
export { EntitiesView } from "./EntitiesView.js";
export { EntityEditor } from "./EntityEditor.js";
export { EntityFields } from "./EntityFields.js";
export { RelationshipEditor } from "./RelationshipEditor.js";
export { RelationshipMap } from "./RelationshipMap.js";
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import "@pubscript/editor/styles";
import "./styles/writing.css";
import "./styles/entities.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+11 -49
View File
@@ -1,52 +1,14 @@
import { Tabs, Badge, Button, EmptyState } from "@pubscript/ui";
const CHARACTERS = [
{ id: "c1", name: "Mara Vell", role: "Protagonist", kind: "Human" },
{ id: "c2", name: "Old Corvin", role: "Mentor", kind: "Human" },
{ id: "c3", name: "The Cartographer", role: "Antagonist", kind: "Unknown" },
];
import { EntitiesView } from "../features/entities/index.js";
/**
* Entities route — Chunk #4 entity engine.
*
* Replaces the placeholder character list with the full entity engine: a
* tabbed, fuzzy-searchable directory of people, places, and things, plus a
* relationship map and the create/edit/delete drawer.
*/
export function Characters() {
return (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Characters</h1>
<Button variant="primary">+ New character</Button>
</div>
<Tabs
defaultActive="characters"
tabs={[
{
id: "characters",
label: "All",
badge: CHARACTERS.length,
content: (
<div style={{ display: "grid", gap: 10 }}>
{CHARACTERS.map((c) => (
<div key={c.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
<div>
<div style={{ fontWeight: 600 }}>{c.name}</div>
<div className="ps-settings-desc">{c.role}</div>
</div>
<Badge variant="accent">{c.kind}</Badge>
</div>
))}
</div>
),
},
{
id: "locations",
label: "Locations",
content: <EmptyState icon="📍" title="Locations" description="Locations appear here once added to a chapter." />,
},
{
id: "factions",
label: "Factions",
content: <EmptyState icon="🏳️" title="No factions yet" description="Group characters into factions to track loyalties." />,
},
]}
/>
</div>
);
return <EntitiesView />;
}
export default Characters;
+85 -1
View File
@@ -14,10 +14,12 @@ import {
type AutosaveState,
type Chapter,
type DocumentRef,
type Entity,
type ProjectMeta,
type StorageAdapter,
} from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
import { createUlid } from "@pubscript/core";
import { create } from "zustand";
import { seedDemoProject } from "./seed.js";
@@ -31,6 +33,10 @@ interface ProjectStore {
error: string | null;
autosave: AutosaveState;
// --- entities (Chunk #4) ---
entities: Entity[];
entitiesLoaded: boolean;
// --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise<void>;
@@ -40,9 +46,44 @@ interface ProjectStore {
scheduleSave: (ref: DocumentRef) => void;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
// --- entity actions ---
/** Load all entities for the active project, optionally filtered by kind. */
loadEntities: (kind?: string) => Promise<void>;
/** Create a new entity of the given kind ("character" | "world" | "note"). */
createEntity: (kind: string) => Promise<Entity>;
/** Persist an updated entity. */
updateEntity: (entity: Entity) => Promise<void>;
/** Delete an entity by id. */
deleteEntity: (id: string) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set) => {
/** Build a blank entity of the given kind for the active project. */
function emptyEntity(kind: string, projectId: string): Entity {
const id = createUlid();
if (kind === "character") {
return {
id,
projectId,
name: "",
role: "",
bio: "",
appearance: "",
voice: "",
relationships: [],
arc: "",
status: "active",
tags: [],
};
}
if (kind === "note") {
return { id, projectId, body: "", pinned: false, tags: [] };
}
// world
return { id, projectId, kind: "location", name: "", fields: {} };
}
export const useProjectStore = create<ProjectStore>((set, get) => {
let autosave: ReturnType<typeof createAutosave> | null = null;
return {
@@ -53,6 +94,8 @@ export const useProjectStore = create<ProjectStore>((set) => {
status: "idle",
error: null,
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
entities: [],
entitiesLoaded: false,
init: async () => {
set({ status: "loading", error: null });
@@ -76,6 +119,9 @@ export const useProjectStore = create<ProjectStore>((set) => {
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
set({ adapter, project, chapters, activeChapterId, status: "ready" });
// Load entities for the entity engine.
await get().loadEntities();
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
}
@@ -100,5 +146,43 @@ export const useProjectStore = create<ProjectStore>((set) => {
void autosave.flush();
}
},
loadEntities: async (kind) => {
const { adapter, project } = get();
if (!adapter || !project) return;
const entities = await adapter.listEntities(project.id, kind);
set({ entities, entitiesLoaded: true });
},
createEntity: async (kind) => {
const { adapter, project } = get();
if (!adapter || !project) throw new Error("No active project.");
const entity = emptyEntity(kind, project.id);
await adapter.saveEntity(entity);
set((state) => ({ entities: [...state.entities, entity] }));
return entity;
},
updateEntity: async (entity) => {
const { adapter } = get();
if (!adapter) throw new Error("No storage adapter.");
await adapter.saveEntity(entity);
set((state) => ({
entities: state.entities.map((e) => (e.id === entity.id ? entity : e)),
}));
},
deleteEntity: async (id) => {
const { adapter, project } = get();
if (!adapter || !project) throw new Error("No active project.");
const entity = get().entities.find((e) => e.id === id);
if (entity) {
const kind = "fields" in entity ? "world" : "body" in entity ? "note" : "character";
await adapter.deleteEntity(project.id, kind, id);
}
set((state) => ({
entities: state.entities.filter((e) => e.id !== id),
}));
},
};
});
+50 -3
View File
@@ -3,24 +3,49 @@ import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
export type Density = "compact" | "comfortable";
/** Body typeface for the writing column. All are system stacks (no web fonts). */
export type FontFamily = "serif" | "sans" | "mono";
export const FONT_FAMILIES: { value: FontFamily; label: string; css: string }[] = [
{ value: "serif", label: "Georgia Serif", css: 'Georgia, "Times New Roman", serif' },
{ value: "sans", label: "System Sans", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
{ value: "mono", label: "Mono", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
];
/** Resolve a stored font-family enum to its CSS font-family string. */
export function fontFamilyCss(value: FontFamily): string {
const found = FONT_FAMILIES.find((f) => f.value === value);
return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
}
export interface AppSettings {
theme: ThemeId;
accentColor: string;
density: Density;
fontFamily: FontFamily;
fontSize: number;
leading: number;
measure: number;
showLineNumbers: boolean;
wordCountGoal: number;
autoSave: boolean;
focusMode: boolean;
typewriterMode: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
theme: "ink",
accentColor: "#7c8bf5",
density: "comfortable",
fontFamily: "serif",
fontSize: 15,
leading: 1.7,
measure: 720,
showLineNumbers: true,
wordCountGoal: 80_000,
autoSave: true,
focusMode: false,
typewriterMode: false,
};
const STORAGE_KEY = "pubscript.settings.v1";
@@ -46,14 +71,31 @@ export const useSettings = create<SettingsStore>((set, get) => {
const initial = loadSettings();
// Apply the persisted theme + font on boot.
applyTheme(initial.theme);
applyFontSettings({ size: initial.fontSize });
applyFontSettings({
size: initial.fontSize,
leading: initial.leading,
measure: initial.measure,
family: fontFamilyCss(initial.fontFamily),
});
return {
...initial,
set: (patch) => {
const next = { ...get(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
if (patch.fontSize != null) applyFontSettings({ size: next.fontSize });
if (
patch.fontSize != null ||
patch.leading != null ||
patch.measure != null ||
patch.fontFamily != null
) {
applyFontSettings({
size: next.fontSize,
leading: next.leading,
measure: next.measure,
family: fontFamilyCss(next.fontFamily),
});
}
set(next);
},
setTheme: (theme) => {
@@ -63,7 +105,12 @@ export const useSettings = create<SettingsStore>((set, get) => {
reset: () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(DEFAULT_SETTINGS.theme);
applyFontSettings({ size: DEFAULT_SETTINGS.fontSize });
applyFontSettings({
size: DEFAULT_SETTINGS.fontSize,
leading: DEFAULT_SETTINGS.leading,
measure: DEFAULT_SETTINGS.measure,
family: fontFamilyCss(DEFAULT_SETTINGS.fontFamily),
});
set({ ...DEFAULT_SETTINGS });
},
};
+466
View File
@@ -0,0 +1,466 @@
/**
* Entity engine styles (apps/web).
*
* Styles for the Chunk #4 entity engine UI: the tabbed directory, the fuzzy
* search bar, the entity card grid, the create/edit drawer, the relationship
* editor, and the force-directed relationship map. These sit on top of the
* `@pubscript/ui` component kit (AppShell.css) and reuse its design tokens
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
*/
/* Full-width container for the entity engine (overrides .ps-content narrowness). */
.ps-entities-view {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ps-entities-view > .ps-tabs {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ---- Search bar + new-entity actions ---- */
.ps-entity-search {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.ps-entity-search-input {
flex: 1;
min-width: 240px;
}
.ps-entity-new-group {
display: flex;
gap: 8px;
}
/* ---- Entity card grid ---- */
.ps-entity-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
padding-bottom: 96px;
}
.ps-entity-card {
text-align: left;
border: 1px solid var(--ps-border);
border-radius: 10px;
padding: 14px 16px;
background: var(--ps-surface-2);
cursor: pointer;
transition: border-color 0.15s, transform 0.1s, box-shadow 0.15s;
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--ps-font-ui);
}
.ps-entity-card:hover {
border-color: var(--ps-accent);
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.ps-entity-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ps-entity-type-badge {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
color: var(--ps-accent);
}
.ps-type-character {
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
color: var(--ps-accent);
}
.ps-type-worldItem {
background: color-mix(in srgb, #8a5cf5 16%, transparent);
color: #8a5cf5;
}
.ps-entity-card-name {
font-size: 16px;
font-weight: 600;
color: var(--ps-text);
margin: 0;
word-break: break-word;
}
.ps-entity-card-sub {
font-size: 13px;
color: var(--ps-text-muted);
margin: 0;
}
.ps-entity-card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.ps-entity-tag {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
background: var(--ps-surface);
color: var(--ps-text-muted);
border: 1px solid var(--ps-border);
}
/* ---- Relationship map ---- */
.ps-entity-map {
padding: 8px;
display: flex;
justify-content: center;
}
.ps-relationship-map {
width: 100%;
max-width: 640px;
height: auto;
border: 1px solid var(--ps-border);
border-radius: 10px;
background: var(--ps-surface-2);
}
.ps-relationship-empty {
display: flex;
align-items: center;
justify-content: center;
height: 420px;
color: var(--ps-text-muted);
font-size: 15px;
}
.ps-relationship-node {
transition: transform 0.1s;
}
.ps-relationship-label {
font-family: var(--ps-font-ui);
}
/* ---- Entity editor drawer ---- */
.ps-entity-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.ps-entity-editor-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 16px;
border-top: 1px solid var(--ps-border);
}
.ps-spacer {
flex: 1;
}
.ps-entity-delete {
margin-right: auto;
}
/* ---- Generic entity fields ---- */
.ps-entity-fields {
display: flex;
flex-direction: column;
}
.ps-field-span-2 {
grid-column: span 2;
}
.ps-textarea {
resize: vertical;
min-height: 60px;
font-family: var(--ps-font-ui);
line-height: 1.5;
}
.ps-field-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--ps-text);
}
/* ---- Tag field ---- */
.ps-tag-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.ps-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ps-tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 6px 2px 10px;
border-radius: 999px;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
color: var(--ps-text);
}
.ps-tag-remove {
border: none;
background: none;
cursor: pointer;
color: var(--ps-text-muted);
font-size: 15px;
line-height: 1;
padding: 0 2px;
}
.ps-tag-remove:hover {
color: var(--ps-danger);
}
/* ---- Relationship editor ---- */
.ps-relationship-editor {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--ps-border);
}
.ps-relationship-list {
list-style: none;
margin: 8px 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ps-relationship-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: 8px;
}
.ps-relationship-kind {
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
color: var(--ps-accent);
}
.ps-relationship-target {
flex: 1;
font-size: 14px;
color: var(--ps-text);
}
.ps-relationship-remove {
border: none;
background: none;
cursor: pointer;
color: var(--ps-text-muted);
font-size: 18px;
line-height: 1;
padding: 0 4px;
}
.ps-relationship-remove:hover {
color: var(--ps-danger);
}
.ps-relationship-add {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.ps-relationship-target-select,
.ps-relationship-kind-select {
flex: 1;
min-width: 120px;
}
.ps-relationship-note {
flex: 2;
min-width: 120px;
}
/* ---- Misc ---- */
.ps-muted {
color: var(--ps-text-muted);
font-size: 14px;
}
/* ---- Toolbar + tabs ---- */
.ps-entity-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.ps-entity-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--ps-border);
padding-bottom: -1px;
}
.ps-entity-tab {
background: none;
border: none;
padding: 8px 12px;
font-size: 14px;
font-family: var(--ps-font-ui);
color: var(--ps-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.ps-entity-tab:hover {
color: var(--ps-text);
}
.ps-entity-tab.active {
color: var(--ps-text);
border-bottom-color: var(--ps-accent);
font-weight: 600;
}
.ps-entity-new-kind {
min-width: 120px;
}
/* ---- Entity editor drawer header/body ---- */
.ps-entity-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ps-entity-editor-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--ps-text);
}
.ps-entity-editor-body {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding: 4px 2px;
}
.ps-icon-btn {
border: none;
background: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--ps-text-muted);
padding: 0 4px;
}
.ps-icon-btn:hover {
color: var(--ps-text);
}
/* ---- Relationship section + map ---- */
.ps-relationship-section {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--ps-border);
}
.ps-relationship-section h3 {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--ps-text-muted);
margin: 0 0 8px;
}
.ps-relationship-map-wrap {
margin-top: 12px;
display: flex;
justify-content: center;
}
.ps-relationship-svg {
width: 100%;
max-width: 520px;
height: auto;
}
.ps-relationship-map-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--ps-text-muted);
font-size: 14px;
}
.ps-rel-edge {
stroke: var(--ps-border);
stroke-width: 1.5;
}
.ps-rel-node {
stroke: var(--ps-surface);
stroke-width: 2;
transition: transform 0.1s;
}
.ps-rel-node-label {
font-family: var(--ps-font-ui);
font-size: 10px;
fill: var(--ps-text-muted);
pointer-events: none;
}