From 4efa71515e53e4f5eade88cebde942211b68cc70 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Sun, 25 Oct 2026 22:42:55 -0500 Subject: [PATCH] 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. --- CHUNK#4.md | 24 +- PLAN.md | 6 +- apps/web/src/App.tsx | 2 +- apps/web/src/components/Settings.tsx | 74 ++- apps/web/src/components/WritingView.tsx | 107 +++- .../src/features/entities/EntitiesView.tsx | 186 +++++++ .../src/features/entities/EntityEditor.tsx | 109 ++++ .../src/features/entities/EntityFields.tsx | 165 +++++++ .../features/entities/RelationshipEditor.tsx | 135 +++++ .../src/features/entities/RelationshipMap.tsx | 167 +++++++ apps/web/src/features/entities/index.ts | 12 + apps/web/src/main.tsx | 1 + apps/web/src/routes/Characters.tsx | 60 +-- apps/web/src/store/project-store.ts | 86 +++- apps/web/src/store/settings.ts | 53 +- apps/web/src/styles/entities.css | 466 ++++++++++++++++++ packages/core/src/autosave.test.ts | 10 + packages/core/src/backlinks.ts | 18 + packages/core/src/consistency.test.ts | 171 +++++++ packages/core/src/consistency.ts | 130 +++++ packages/core/src/fuzzy.test.ts | 75 +++ packages/core/src/fuzzy.ts | 111 +++++ packages/core/src/index.ts | 2 + packages/core/src/storage-local.ts | 54 ++ packages/core/src/storage-server.ts | 35 ++ packages/core/src/storage.contract.test.ts | 106 +++- packages/core/src/storage.ts | 12 + packages/core/src/types.ts | 6 +- packages/editor/src/index.ts | 4 + packages/editor/src/mention.ts | 115 +++++ packages/editor/src/nodes/MentionPicker.tsx | 182 +++++++ packages/editor/src/schema.ts | 2 + packages/ui/src/AppShell.css | 109 ++++ server/src/index.ts | 18 +- server/src/mapping.ts | 17 +- server/src/routes.ts | 16 +- 36 files changed, 2764 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/features/entities/EntitiesView.tsx create mode 100644 apps/web/src/features/entities/EntityEditor.tsx create mode 100644 apps/web/src/features/entities/EntityFields.tsx create mode 100644 apps/web/src/features/entities/RelationshipEditor.tsx create mode 100644 apps/web/src/features/entities/RelationshipMap.tsx create mode 100644 apps/web/src/features/entities/index.ts create mode 100644 apps/web/src/styles/entities.css create mode 100644 packages/core/src/consistency.test.ts create mode 100644 packages/core/src/consistency.ts create mode 100644 packages/core/src/fuzzy.test.ts create mode 100644 packages/core/src/fuzzy.ts create mode 100644 packages/editor/src/mention.ts create mode 100644 packages/editor/src/nodes/MentionPicker.tsx diff --git a/CHUNK#4.md b/CHUNK#4.md index 3cfee75..61ece4f 100644 --- a/CHUNK#4.md +++ b/CHUNK#4.md @@ -4,6 +4,8 @@ **Purpose:** Make the story's moving parts first-class: character sheets with typed relationships, flexible world items, fast fuzzy search, backlinks, and `@mention` links from prose to entities. After this chunk, the "tools to facilitate and integrate characters, world building" requirement is fully delivered. +**Status (updated 2026-10-25):** 🟑 In progress. Data layer and entity UI layer complete and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; 65 tests green across the workspace). See "Remaining" below. + --- ## Steps @@ -40,14 +42,22 @@ --- ## Acceptance criteria (all must pass) -- [ ] Create a character with all field types (incl. list + link) via the generic renderer β€” no custom code added. -- [ ] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test). -- [ ] `@` autocomplete β†’ chip β†’ popover β†’ open sheet, all working; works while typing fast (no stale menus). -- [ ] Backlinks correct and live-updating (save doc β†’ "Appears in" updates within one event round-trip). -- [ ] Consistency check flags the seeded dead-character-after-death case. -- [ ] Relationship map renders and navigates. +- [~] Create a character with all field types (incl. list + link) via the generic renderer β€” no custom code added. Generic renderer done for `text`/`textarea`/`checkbox`/`tags` (list); `link` field type pending. +- [x] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test). +- [~] `@` autocomplete β†’ chip β†’ popover β†’ open sheet, all working; works while typing fast (no stale menus). Autocomplete + chip + open sheet done; hover popover pending. +- [x] Backlinks correct and live-updating (save doc β†’ "Appears in" updates within one event round-trip). +- [ ] Consistency check flags the seeded dead-character-after-death case. Logic in `packages/core/src/consistency.ts` done; UI wiring + seeded case pending. +- [x] Relationship map renders and navigates. - [ ] Global palette finds a chapter, a character, and a note from one box. -- [ ] Contract suite still green on both adapters. +- [x] Contract suite still green on both adapters. + +## Remaining work +- Wire `findInconsistencies()` into the Characters tab as a warnings list (Chunk 3.2). +- Index entities in the global `Ctrl/Cmd+K` palette (Chunk 3.1). +- Context rail (right side of WritingView): this chapter's mentioned characters, pinned entities, related notes (Chunk 3.2). +- Extend `server/src/seed.ts`: full character sheets (bio, voice, 2 relationships each) + 5 world items (Chunk 4.1). +- `docs/DATA_MODEL.md`: the entity registry table (kind β†’ fields) as the human reference (Chunk 4.2). +- E2E: create character β†’ `@mention` it in a chapter β†’ backlinks tab lists the chapter β†’ open sheet from the chip (Chunk 5.3). ## Definition of done The author's canon β€” who, what, where β€” is structured, searchable, linked to the prose, and checkable. This is the data the AI roles will reason from in Chunk 9. diff --git a/PLAN.md b/PLAN.md index c51ed0f..2351925 100644 --- a/PLAN.md +++ b/PLAN.md @@ -270,7 +270,7 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a | 1 | **Scaffold & Foundations** | 1 | βœ… Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store | | 2 | **Data Model, Storage & Auth** | 3 | ⬜ Not started | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests | | 3 | **Writing Surface** | 3 | 🟑 In progress | TipTap editor βœ…, document tree βœ…, focus mode βœ…, Markdown I/O βœ…, autosave βœ…, writing view wired to storage adapter βœ… (UI-disappears crash fixed), fonts/logo polish 🟑 | -| 4 | **Characters & World-Building** | 2 | ⬜ Not started | Entity schema, character sheets, world items, search, mentions, backlinks | +| 4 | **Characters & World-Building** | 2 | 🟑 In progress | Entity schema βœ…, character sheets βœ…, world items βœ…, fuzzy search βœ…, mentions βœ…, backlinks βœ…, entity UI (sheets/search/relationships/map) βœ…; pending: consistency UI, palette search, context rail, seed extension, DATA_MODEL.md, E2E | | 5 | **Plot & Whiteboard** | 3 | ⬜ Not started | Beat board + templates, timeline, React Flow whiteboard (200-node perf) | | 6 | **Export Pipeline** | 3 | ⬜ Not started | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation | | 7 | **Spell & Grammar** | 2 | ⬜ Not started | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel | @@ -281,7 +281,7 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a **Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable. -**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell). +**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell). CHUNK #2 and #3 are committed and CI-green. CHUNK #4 is in progress β€” its data layer (entity schema, fuzzy search, backlinks, consistency check, `@mention` autocomplete) and entity UI layer (character/world sheets, fuzzy search, relationship editor + map) are built and green, with a few items remaining (consistency UI wiring, palette search, context rail, seed extension, `docs/DATA_MODEL.md`, E2E). --- @@ -298,4 +298,4 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a --- -*CHUNK #1 is complete. Next: read `CHUNK#2.md` and start executing. Each chunk's acceptance criteria are the gate to the next.* +*CHUNK #1–#3 are complete and CI-green. Currently executing CHUNK #4 (Characters & World-Building). Each chunk's acceptance criteria are the gate to the next.* diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 54b08ce..a60f616 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 }, diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx index e929b7b..755bb12 100644 --- a/apps/web/src/components/Settings.tsx +++ b/apps/web/src/components/Settings.tsx @@ -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() { ]} /> + + +
+

Typography

+ +
+
+
Font
+
Body typeface for the writing column.
+
+ 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` }))} + /> +
+ +
+
+
Line spacing
+
Leading between body lines.
+
+ set({ measure: Number(e.target.value) })} + options={[600, 660, 720, 780, 840].map((n) => ({ value: String(n), label: `${n}px` }))} />
@@ -102,6 +142,34 @@ export function Settings() { /> + +
+
+
Focus mode
+
Dim everything but the block under the cursor. Toggle with F2.
+
+ +
+ +
+
+
Typewriter mode
+
Keep the active block centered as you type. Toggle with βŒ˜β‡§T.
+
+ +
diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx index d7dbafb..124cdeb 100644 --- a/apps/web/src/components/WritingView.tsx +++ b/apps/web/src/components/WritingView.tsx @@ -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(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("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) { ))} + +
+ + +
+
{autosaveLabel(autosave)}
-
-
+
+
diff --git a/apps/web/src/features/entities/EntitiesView.tsx b/apps/web/src/features/entities/EntitiesView.tsx new file mode 100644 index 0000000..b8fff4c --- /dev/null +++ b/apps/web/src/features/entities/EntitiesView.tsx @@ -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("all"); + const [query, setQuery] = React.useState(""); + const [newKind, setNewKind] = React.useState("character"); + const [draft, setDraft] = React.useState(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 ( +
+
+
+ {TABS.map((t) => ( + + ))} +
+
+ setQuery(ev.target.value)} + /> +
+ + {visible.length === 0 ? ( +

No entities match.

+ ) : ( +
+ {visible.map((e) => ( + + ))} +
+ )} + + setOpen(false)} + onSave={save} + onDelete={remove} + /> +
+ ); +} + +/** 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; +} diff --git a/apps/web/src/features/entities/EntityEditor.tsx b/apps/web/src/features/entities/EntityEditor.tsx new file mode 100644 index 0000000..26544aa --- /dev/null +++ b/apps/web/src/features/entities/EntityEditor.tsx @@ -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); + + 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) => { + setDraft({ ...draft, ...p }); + }; + + const save = () => { + onSave(draft); + onClose(); + }; + + return ( + + ); +} diff --git a/apps/web/src/features/entities/EntityFields.tsx b/apps/web/src/features/entities/EntityFields.tsx new file mode 100644 index 0000000..a9646d4 --- /dev/null +++ b/apps/web/src/features/entities/EntityFields.tsx @@ -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)[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) => void; +} + +function renderField( + field: FieldDef, + value: string | string[], + onChange: (v: string | string[]) => void, +) { + const label = ( + + ); + + switch (field.type) { + case "textarea": + return ( +
+ {label} +