diff --git a/src/widgets/Board/ui/RoleField/RoleField.stories.svelte b/src/widgets/Board/ui/RoleField/RoleField.stories.svelte
new file mode 100644
index 0000000..87103ab
--- /dev/null
+++ b/src/widgets/Board/ui/RoleField/RoleField.stories.svelte
@@ -0,0 +1,83 @@
+
+
+
+
+ (headerText = t),
+ }}
+>
+ {#snippet template(args: ComponentProps)}
+
+ {/snippet}
+
+
+ (bodyText = t),
+ }}
+>
+ {#snippet template(args: ComponentProps)}
+
+ {/snippet}
+
+
+ (emptyText = t),
+ }}
+>
+ {#snippet template(args: ComponentProps)}
+
+ {/snippet}
+
diff --git a/src/widgets/Board/ui/RoleField/RoleField.svelte b/src/widgets/Board/ui/RoleField/RoleField.svelte
new file mode 100644
index 0000000..27cb78e
--- /dev/null
+++ b/src/widgets/Board/ui/RoleField/RoleField.svelte
@@ -0,0 +1,127 @@
+
+
+
+
+
(focused = true)}
+ onblur={handleBlur}
+ onpaste={handlePaste}
+ onkeydown={handleKeydown}
+ class="
+ w-full min-h-[1.4em] outline-none whitespace-pre-wrap break-words
+ empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
+ focus:outline-none
+ "
+ style:font-family={`"${fontName}"`}
+ style:font-size="{size}px"
+ style:font-weight={weight}
+ style:line-height={leading}
+ style:letter-spacing="{tracking}px"
+ >
+
+
diff --git a/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts b/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts
new file mode 100644
index 0000000..3b83a71
--- /dev/null
+++ b/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts
@@ -0,0 +1,51 @@
+import {
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/svelte';
+import { tick } from 'svelte';
+import {
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import RoleField from './RoleField.svelte';
+
+const baseProps = { fontName: 'Georgia', size: 24, weight: 400, leading: 1.4, tracking: 0 };
+
+describe('RoleField', () => {
+ it('renders the initial text', async () => {
+ render(RoleField, { props: { role: 'header', text: 'Hello', oncommit: () => {}, ...baseProps } });
+ await tick();
+ expect(screen.getByRole('textbox').textContent).toBe('Hello');
+ });
+
+ it('commits the field text on blur (not on input)', async () => {
+ const oncommit = vi.fn();
+ render(RoleField, { props: { role: 'body', text: 'Start', oncommit, ...baseProps } });
+ await tick();
+ const field = screen.getByRole('textbox');
+ field.textContent = 'Edited';
+ await fireEvent.blur(field);
+ expect(oncommit).toHaveBeenCalledWith('Edited');
+ });
+
+ it('prevents Enter on the header role (single-line)', async () => {
+ render(RoleField, { props: { role: 'header', text: 'Title', oncommit: () => {}, ...baseProps } });
+ await tick();
+ const field = screen.getByRole('textbox');
+ const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
+ field.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('allows Enter on the body role (multi-line)', async () => {
+ render(RoleField, { props: { role: 'body', text: 'Para', oncommit: () => {}, ...baseProps } });
+ await tick();
+ const field = screen.getByRole('textbox');
+ const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
+ field.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
diff --git a/vitest.setup.jsdom.ts b/vitest.setup.jsdom.ts
index 36b8174..7df9e82 100644
--- a/vitest.setup.jsdom.ts
+++ b/vitest.setup.jsdom.ts
@@ -19,6 +19,19 @@ Element.prototype.animate = vi.fn().mockReturnValue({
// jsdom lacks SVG geometry methods
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
+// jsdom lacks innerText; back it with textContent so contenteditable specs work.
+if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
+ Object.defineProperty(HTMLElement.prototype, 'innerText', {
+ configurable: true,
+ get(this: HTMLElement) {
+ return this.textContent ?? '';
+ },
+ set(this: HTMLElement, value: string) {
+ this.textContent = value;
+ },
+ });
+}
+
// Robust localStorage mock for jsdom environment
const localStorageMock = (() => {
let store: Record = {};