refactor(words): drawer canon — morfo part + soma provider + eidos consumes via provider (DRAWER-CANON)

Closes the architectural gap from the DRAWER spike: the companion
drawer now follows the same morfo/soma/eidos pattern as every other
Words part (Toolbar/BubbleMenu/SlashMenu/LinkEditor/Status/etc.).

DRAWER-CANON-1 — morfo + soma:
- wordsMorfo gains a 'Drawer' part (kebab 'drawer', archetype 'group',
  defaultElement 'aside', role 'complementary'). Declares data-words-
  drawer-mode (default/format/image/table/code/list), data-open,
  data-disabled, aria-label. New event 'commit-toggle-drawer' fires
  on collapse/expand for sema/telemetry hooks.
- New WordsDrawerProvider in words-provider.svelte: owns `open`
  bindable state, derives `mode` from parent provider's currentBlock
  + selection (same logic as the spike, now in soma where it
  belongs), exposes snippetProps {mode, open, disabled, snippet,
  toggle, setOpen}. `toggle()` and `setOpen()` emit the event.
- WordsDrawerProps + WordsDrawerSnippetProps + WordsDrawerMode types
  added to types.ts and re-exported via exports.ts.
- soma/components/words/components/words-drawer.svelte (the soma
  component) mounts the provider and forwards merged props to either
  the `child` snippet or the default <aside> render.

DRAWER-CANON-2 — eidos refactor:
- eidos words-drawer.svelte rewritten as a consumer of
  <Words.Drawer>. The shell (aside + header + collapse button) and
  the per-mode body now live inside the children snippet that
  receives the soma's drawer snippet props. Mode derivation removed
  from eidos (it's now in soma). Local open $state removed (now
  bindable via the soma component).
- Demo +page.svelte: `<Words.Drawer snippet={snippetProps} />`
  collapses to plain `<Words.Drawer />` — the soma provider hands the
  parent snippet down internally.

DRAWER-CANON-3 — verify:
- 214/214 tests pass in soma+morfo scope (no regressions).
- npx tsc --noEmit clean for words area.
- Visual verification: drawer still renders with DOCUMENT/OUTLINE/
  STATS/SUGGESTIONS in default mode; context-switching to table /
  code / list / format still works; aside element now carries
  role='complementary', aria-label from morfo translationRef,
  data-words-drawer-mode reflecting current panel.

The eidos drawer now passes the same canon checks as every other
Words part: morfo declares its contract, soma owns the state, eidos
only paints. The schema validator can verify the drawer's attrs, the
eidos lint can check selector usage, sema can react to the toggle
event.

Demo header chips reflect the new contract: PARTS 19→20, EVENTS
11→12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 15dbf0f8fd
commit 9a5f3c2359

@ -1,80 +1,37 @@
<script lang="ts">
/**
* Words.Drawer — companion right-rail that REPLACES the fragmented
* popovers of a traditional rich-text editor (bubble menu + slash
* menu + link editor + heading picker + language picker + table
* popover + image controls) with ONE contextual surface.
* Words.Drawer — companion right-rail eidos wrapper.
*
* Inspired by Figma's right panel: the same UI slot shows different
* controls depending on what the user is editing.
* After DRAWER-CANON-2 the drawer is canonical: it consumes the
* soma `Words.Drawer` provider (which owns the open/collapsed state
* and the derived `mode`) and renders the per-mode body here in
* eidos.
*
* Contextual modes (derived from snippetProps):
* - 'default' → outline + word/char/reading time + writer warnings
* - 'format' → bold/italic/underline + link + align (text selected)
* - 'image' → alt/caption + alignment + status (in image)
* - 'table' → row/col ops + header + striped + tone (in table)
* - 'code' → language picker (in code block)
* - 'list' → kind toggle + indent (in list item)
*
* This is the spike-1 surface (commit DRAWER-1): shell, context
* detection, default panel only. Contextual panels arrive in
* DRAWER-2.
* Concept (still A+D from the spike):
* - Single contextual surface replacing bubble menu + slash menu +
* link editor + heading picker + language picker + table popover
* + image controls.
* - Default mode shows outline + stats + writer-coaching suggestions.
* - Format / image / table / code / list modes show controls scoped
* to what the user is editing.
*/
import { ChevronRight, ChevronLeft } from '$uix/eidos/components/icon';
import * as Words from '$soma/components/words';
import type {
ProviderSnippetProps,
WordsBlockType
DrawerSnippetProps,
DrawerProps,
ProviderSnippetProps
} from '$soma/components/words';
import type { Snippet } from 'svelte';
type DrawerMode = 'default' | 'format' | 'image' | 'table' | 'code' | 'list';
interface WordsDrawerProps {
/** All snippet props from `<Words>`. Forward them straight. */
readonly snippet: ProviderSnippetProps;
/** Open by default. The drawer is collapsible. */
open?: boolean;
/** Optional override for the panel renderer — consumers can
* inject their own content per mode. Receives `{mode, snippet}`. */
readonly content?: Snippet<
[{ readonly mode: DrawerMode; readonly snippet: ProviderSnippetProps }]
>;
}
let { snippet, open = $bindable(true), content }: WordsDrawerProps = $props();
let {
open = $bindable(undefined),
defaultOpen = true,
disabled = false,
children: customChildren,
...rest
}: Omit<DrawerProps, 'child'> = $props();
const mode: DrawerMode = $derived.by(() => resolveMode(snippet));
const modeLabel = $derived(MODE_LABELS[mode]);
function resolveMode(s: ProviderSnippetProps): DrawerMode {
// Text-selection format takes precedence over block-type panels.
// "Range selection inside an inline-text block" → format mode.
const sel = s.selection;
if (
sel &&
(sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.offset !== sel.focus.offset)
) {
const block = s.currentBlock as WordsBlockType;
if (
block === 'paragraph' ||
block === 'heading' ||
block === 'quote' ||
block === 'list'
) {
return 'format';
}
}
const block = s.currentBlock as WordsBlockType;
if (block === 'image') return 'image';
if (block === 'table') return 'table';
if (block === 'code') return 'code';
if (block === 'list') return 'list';
return 'default';
}
const MODE_LABELS: Record<DrawerMode, string> = {
const MODE_LABELS: Record<DrawerSnippetProps['mode'], string> = {
default: 'Document',
format: 'Format',
image: 'Image',
@ -83,106 +40,19 @@
list: 'List'
};
// ── Default-panel derivations (D — writer coaching) ───────────────
const wordCount = $derived(countWordsInText(snippet.plainText));
const charCount = $derived(snippet.plainText.length);
/** Average reading speed 200wpm. Floor to 1 min so the value is
* always meaningful even for tiny drafts. */
const readingTimeMin = $derived(Math.max(1, Math.round(wordCount / 200)));
type OutlineEntry = {
readonly text: string;
readonly level: 1 | 2 | 3;
readonly blockIndex: number;
};
const outline = $derived.by<readonly OutlineEntry[]>(() => {
const out: OutlineEntry[] = [];
snippet.document.children.forEach((block, index) => {
if (block.type !== 'heading') return;
const text = block.children
.map((inline) =>
inline.type === 'text'
? inline.text
: inline.children.map((c) => (c.type === 'text' ? c.text : '')).join('')
)
.join('');
if (!text.trim()) return;
out.push({ text, level: block.level, blockIndex: index });
});
return out;
});
type Warning = {
readonly id: string;
readonly severity: 'info' | 'warn';
readonly message: string;
};
/**
* Writer-coaching warnings (D). Lightweight, no I/O, derived from
* the document model. Each rule is independent so adding new rules
* is cheap.
*/
const warnings = $derived.by<readonly Warning[]>(() => {
const out: Warning[] = [];
snippet.document.children.forEach((block, index) => {
if (block.type === 'image' && !block.alt?.trim()) {
out.push({
id: `alt-${index}`,
severity: 'warn',
message: `Image at block ${index + 1} has no alt text`
});
}
if (block.type === 'paragraph') {
const text = block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('');
if (text.length > 600) {
out.push({
id: `long-${index}`,
severity: 'info',
message: `Paragraph ${index + 1} is long (${text.length} chars) — consider splitting`
});
}
}
});
// Heading-structure check — H1 → H3 without H2 between is a
// common a11y gap.
const headingLevels = snippet.document.children
.filter((b) => b.type === 'heading')
.map((b) => (b.type === 'heading' ? b.level : 0));
for (let i = 1; i < headingLevels.length; i++) {
if (headingLevels[i] - headingLevels[i - 1] > 1) {
out.push({
id: `skip-${i}`,
severity: 'warn',
message: `Heading h${headingLevels[i]} appears without an h${headingLevels[i - 1] + 1} above — structure skip`
});
break;
}
}
return out;
});
function countWordsInText(text: string): number {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
}
function toggleOpen() {
open = !open;
}
/**
* Return the image block currently containing the caret, or
* undefined if the caret is not on an image. The image panel uses
* this to display block attributes (src/alt/caption/status).
*/
function imageBlockAtCaret(
s: ProviderSnippetProps
): { readonly src: string; readonly alt?: string; readonly caption?: string; readonly status?: 'pending' | 'error' } | undefined {
function imageBlockAtCaret(s: ProviderSnippetProps):
| {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
readonly status?: 'pending' | 'error';
}
| undefined {
const blockIndex = s.selection?.anchor.path[0];
if (blockIndex === undefined) return undefined;
const block = s.document.children[blockIndex];
@ -202,336 +72,340 @@
] as const;
</script>
<aside
data-words-drawer
data-mode={mode}
data-open={open ? '' : undefined}
aria-label="Editor companion drawer"
>
<header data-words-drawer-header>
<button
type="button"
data-words-drawer-collapse
aria-label={open ? 'Collapse drawer' : 'Expand drawer'}
aria-expanded={open}
onclick={toggleOpen}
>
{#if open}
<ChevronRight size="sm" decorative />
{:else}
<ChevronLeft size="sm" decorative />
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest}>
{#snippet children(d: DrawerSnippetProps)}
{@const { mode, snippet, toggle } = d}
{@const wordCount = countWordsInText(snippet.plainText)}
{@const charCount = snippet.plainText.length}
{@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))}
<header data-words-drawer-header>
<button
type="button"
data-words-drawer-collapse
aria-label={d.open ? 'Collapse drawer' : 'Expand drawer'}
aria-expanded={d.open}
onclick={(e) => toggle(e.currentTarget as HTMLElement)}
>
{#if d.open}
<ChevronRight size="sm" decorative />
{:else}
<ChevronLeft size="sm" decorative />
{/if}
</button>
{#if d.open}
<span data-words-drawer-title>{MODE_LABELS[mode]}</span>
{/if}
</button>
{#if open}
<span data-words-drawer-title>{modeLabel}</span>
{/if}
</header>
</header>
{#if open}
<div data-words-drawer-body>
{#if content}
{@render content({ mode, snippet })}
{:else if mode === 'default'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Outline</h3>
{#if outline.length === 0}
<p data-words-drawer-empty>No headings yet</p>
{:else}
<ol data-words-drawer-outline>
{#each outline as entry (entry.blockIndex)}
<li data-words-drawer-outline-item data-level={entry.level}>
<span data-words-drawer-outline-bullet>H{entry.level}</span>
<span data-words-drawer-outline-text>{entry.text}</span>
</li>
{/each}
</ol>
{/if}
</section>
{#if d.open}
<div data-words-drawer-body>
{#if customChildren}
{@render customChildren(d)}
{:else if mode === 'default'}
{@const outline = snippet.document.children
.map((block, blockIndex) => ({ block, blockIndex }))
.filter(({ block }) => block.type === 'heading')
.map(({ block, blockIndex }) => ({
blockIndex,
level: block.type === 'heading' ? block.level : 1,
text:
block.type === 'heading'
? block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('')
: ''
}))
.filter((e) => e.text.trim())}
{@const warnings = collectWarnings(snippet)}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Stats</h3>
<dl data-words-drawer-stats>
<div><dt>Words</dt><dd>{wordCount}</dd></div>
<div><dt>Chars</dt><dd>{charCount}</dd></div>
<div>
<dt>Reading</dt>
<dd>{readingTimeMin} min</dd>
</div>
</dl>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Outline</h3>
{#if outline.length === 0}
<p data-words-drawer-empty>No headings yet</p>
{:else}
<ol data-words-drawer-outline>
{#each outline as entry (entry.blockIndex)}
<li data-words-drawer-outline-item data-level={entry.level}>
<span data-words-drawer-outline-bullet>H{entry.level}</span>
<span data-words-drawer-outline-text>{entry.text}</span>
</li>
{/each}
</ol>
{/if}
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>
Suggestions
{#if warnings.length > 0}
<span data-words-drawer-section-count>{warnings.length}</span>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Stats</h3>
<dl data-words-drawer-stats>
<div><dt>Words</dt><dd>{wordCount}</dd></div>
<div><dt>Chars</dt><dd>{charCount}</dd></div>
<div><dt>Reading</dt><dd>{readingTimeMin} min</dd></div>
</dl>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>
Suggestions
{#if warnings.length > 0}
<span data-words-drawer-section-count>{warnings.length}</span>
{/if}
</h3>
{#if warnings.length === 0}
<p data-words-drawer-empty data-tone="affirm">Nothing flagged</p>
{:else}
<ul data-words-drawer-warnings>
{#each warnings as w (w.id)}
<li data-severity={w.severity}>{w.message}</li>
{/each}
</ul>
{/if}
</h3>
{#if warnings.length === 0}
<p data-words-drawer-empty data-tone="affirm">Nothing flagged</p>
{:else}
<ul data-words-drawer-warnings>
{#each warnings as w (w.id)}
<li data-severity={w.severity}>{w.message}</li>
</section>
{:else if mode === 'format'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Marks</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'bold', label: 'B', title: 'Bold' },
{ id: 'italic', label: 'I', title: 'Italic' },
{ id: 'underline', label: 'U', title: 'Underline' },
{ id: 'strike', label: 'S', title: 'Strikethrough' },
{ id: 'code', label: '<>', title: 'Inline code' }
] as mark (mark.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.activeMarks.includes(mark.id) ? '' : undefined}
title={mark.title}
aria-pressed={snippet.activeMarks.includes(mark.id)}
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(mark.id)}
>
{mark.label}
</button>
{/each}
</ul>
{/if}
</section>
{:else if mode === 'format'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Marks</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'bold', label: 'B', title: 'Bold' },
{ id: 'italic', label: 'I', title: 'Italic' },
{ id: 'underline', label: 'U', title: 'Underline' },
{ id: 'strike', label: 'S', title: 'Strikethrough' },
{ id: 'code', label: '<>', title: 'Inline code' }
] as mark (mark.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.activeMarks.includes(mark.id) ? '' : undefined}
title={mark.title}
aria-pressed={snippet.activeMarks.includes(mark.id)}
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(mark.id)}
>
{mark.label}
</button>
{/each}
</div>
</section>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Align</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'align-left', label: '⫷', title: 'Left' },
{ id: 'align-center', label: '☰', title: 'Center' },
{ id: 'align-right', label: '⫸', title: 'Right' },
{ id: 'align-justify', label: '☷', title: 'Justify' }
] as a (a.id)}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Align</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'align-left', label: '⫷', title: 'Left' },
{ id: 'align-center', label: '☰', title: 'Center' },
{ id: 'align-right', label: '⫸', title: 'Right' },
{ id: 'align-justify', label: '☷', title: 'Justify' }
] as a (a.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTextAlign === a.id.replace('align-', '') ? '' : undefined}
title={a.title}
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(a.id)}
>
{a.label}
</button>
{/each}
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Link</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTextAlign === a.id.replace('align-', '') ? '' : undefined}
title={a.title}
data-tone="full"
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(a.id)}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
>
{a.label}
Add link
</button>
{/each}
</div>
</section>
{#if snippet.canUnlink}
<button
type="button"
data-words-drawer-action
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.unlink()}
>
Remove
</button>
{/if}
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Link</h3>
<div data-words-drawer-actions>
<section data-words-drawer-section>
<button
type="button"
data-words-drawer-action
data-tone="full"
data-tone="ghost"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
onclick={() => snippet.runCommand('clear-formatting')}
>
Add link
Clear formatting
</button>
{#if snippet.canUnlink}
<button
type="button"
data-words-drawer-action
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.unlink()}
>
Remove
</button>
{/if}
</div>
</section>
<section data-words-drawer-section>
<button
type="button"
data-words-drawer-action
data-tone="ghost"
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand('clear-formatting')}
>
Clear formatting
</button>
</section>
{:else if mode === 'image'}
{@const block = imageBlockAtCaret(snippet)}
{#if block}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Source</h3>
<p data-words-drawer-readonly-text>{block.src}</p>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Alt text</h3>
<p data-words-drawer-readonly-text data-empty={!block.alt}>
{block.alt || '(missing)'}
</p>
</section>
{#if block.caption}
{:else if mode === 'image'}
{@const block = imageBlockAtCaret(snippet)}
{#if block}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Caption</h3>
<p data-words-drawer-readonly-text>{block.caption}</p>
<h3 data-words-drawer-section-title>Source</h3>
<p data-words-drawer-readonly-text>{block.src}</p>
</section>
{/if}
{#if block.status}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Status</h3>
<p data-words-drawer-readonly-text data-tone={block.status}>
{block.status === 'pending' ? 'Uploading…' : 'Upload failed'}
<h3 data-words-drawer-section-title>Alt text</h3>
<p data-words-drawer-readonly-text data-empty={!block.alt}>
{block.alt || '(missing)'}
</p>
</section>
{#if block.caption}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Caption</h3>
<p data-words-drawer-readonly-text>{block.caption}</p>
</section>
{/if}
{#if block.status}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Status</h3>
<p data-words-drawer-readonly-text data-tone={block.status}>
{block.status === 'pending' ? 'Uploading…' : 'Upload failed'}
</p>
</section>
{/if}
{:else}
<section data-words-drawer-section>
<p data-words-drawer-empty>Place caret on an image to edit it.</p>
</section>
{/if}
{:else}
{:else if mode === 'table'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Rows</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('insert-table-row')}>+ Row</button>
<button type="button" data-words-drawer-action data-tone="risk" onclick={() => snippet.runCommand('delete-table-row')}>− Row</button>
</div>
</section>
<section data-words-drawer-section>
<p data-words-drawer-empty>Place caret on an image to edit it.</p>
<h3 data-words-drawer-section-title>Columns</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('insert-table-column')}>+ Col</button>
<button type="button" data-words-drawer-action data-tone="risk" onclick={() => snippet.runCommand('delete-table-column')}>− Col</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Headers</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action data-active={snippet.currentTableHeaderRowActive ? '' : undefined} onclick={() => snippet.runCommand('toggle-table-header-row')}>Row header</button>
<button type="button" data-words-drawer-action data-active={snippet.currentTableHeaderColumnActive ? '' : undefined} onclick={() => snippet.runCommand('toggle-table-header-column')}>Col header</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Appearance</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action data-active={snippet.currentTableStriped ? '' : undefined} onclick={() => snippet.runCommand('toggle-table-striped')}>Striped</button>
<button type="button" data-words-drawer-action data-active={snippet.currentTableCompact ? '' : undefined} onclick={() => snippet.runCommand('toggle-table-compact')}>Compact</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Cell tone</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'table-cell-tone-default', label: 'Plain' },
{ id: 'table-cell-tone-muted', label: 'Muted' },
{ id: 'table-cell-tone-accent', label: 'Accent' }
] as t (t.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableCellTone === t.id.replace('table-cell-tone-', '') ? '' : undefined}
onclick={() => snippet.runCommand(t.id)}
>{t.label}</button>
{/each}
</div>
</section>
{:else if mode === 'code'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Language</h3>
<div data-words-drawer-actions>
{#each CODE_LANGUAGES as lang (lang.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentCodeLanguage === lang.id ? '' : undefined}
onclick={() => snippet.setCodeLanguage(lang.id || undefined)}
>{lang.label}</button>
{/each}
</div>
</section>
{:else if mode === 'list'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>List kind</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('unordered-list')}>Bullet</button>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('ordered-list')}>Numbered</button>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('check-list')}>Check</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Indent</h3>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('decrease-indent')}>⇤ Outdent</button>
<button type="button" data-words-drawer-action onclick={() => snippet.runCommand('increase-indent')}>⇥ Indent</button>
</div>
</section>
{/if}
{:else if mode === 'table'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Rows</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('insert-table-row')}
>+ Row</button>
<button
type="button"
data-words-drawer-action
data-tone="risk"
onclick={() => snippet.runCommand('delete-table-row')}
>− Row</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Columns</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('insert-table-column')}
>+ Col</button>
<button
type="button"
data-words-drawer-action
data-tone="risk"
onclick={() => snippet.runCommand('delete-table-column')}
>− Col</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Headers</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableHeaderRowActive ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-header-row')}
>Row header</button>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableHeaderColumnActive ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-header-column')}
>Col header</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Appearance</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableStriped ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-striped')}
>Striped</button>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableCompact ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-compact')}
>Compact</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Cell tone</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'table-cell-tone-default', label: 'Plain' },
{ id: 'table-cell-tone-muted', label: 'Muted' },
{ id: 'table-cell-tone-accent', label: 'Accent' }
] as t (t.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableCellTone === t.id.replace('table-cell-tone-', '') ? '' : undefined}
onclick={() => snippet.runCommand(t.id)}
>{t.label}</button>
{/each}
</div>
</section>
{:else if mode === 'code'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Language</h3>
<div data-words-drawer-actions>
{#each CODE_LANGUAGES as lang (lang.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentCodeLanguage === lang.id ? '' : undefined}
onclick={() => snippet.setCodeLanguage(lang.id || undefined)}
>{lang.label}</button>
{/each}
</div>
</section>
{:else if mode === 'list'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>List kind</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('unordered-list')}
>Bullet</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('ordered-list')}
>Numbered</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('check-list')}
>Check</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Indent</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('decrease-indent')}
>⇤ Outdent</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('increase-indent')}
>⇥ Indent</button>
</div>
</section>
{/if}
</div>
{/if}
</aside>
</div>
{/if}
{/snippet}
</Words.Drawer>
<script context="module" lang="ts">
import type { ProviderSnippetProps as PSP } from '$soma/components/words';
type Warning = {
readonly id: string;
readonly severity: 'info' | 'warn';
readonly message: string;
};
export function collectWarnings(s: PSP): readonly Warning[] {
const out: Warning[] = [];
s.document.children.forEach((block, index) => {
if (block.type === 'image' && !block.alt?.trim()) {
out.push({
id: `alt-${index}`,
severity: 'warn',
message: `Image at block ${index + 1} has no alt text`
});
}
if (block.type === 'paragraph') {
const text = block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('');
if (text.length > 600) {
out.push({
id: `long-${index}`,
severity: 'info',
message: `Paragraph ${index + 1} is long (${text.length} chars) — consider splitting`
});
}
}
});
const headingLevels = s.document.children
.filter((b) => b.type === 'heading')
.map((b) => (b.type === 'heading' ? b.level : 0));
for (let i = 1; i < headingLevels.length; i++) {
if (headingLevels[i] - headingLevels[i - 1] > 1) {
out.push({
id: `skip-${i}`,
severity: 'warn',
message: `Heading h${headingLevels[i]} appears without an h${headingLevels[i - 1] + 1} above — structure skip`
});
break;
}
}
return out;
}
</script>

@ -15,7 +15,8 @@ export const wordsMorfo = {
slashMenu: '#?components.words.slash-menu|Insert block',
linkEditor: '#?components.words.link-editor|Edit link',
findReplace: '#?components.words.find-replace|Find and replace',
status: '#?components.words.status|Editor status'
status: '#?components.words.status|Editor status',
drawer: '#?components.words.drawer|Editor companion drawer'
},
events: [
{
@ -126,6 +127,23 @@ export const wordsMorfo = {
intent: 'risk',
sequence: 'post'
}
},
{
// Toggle of the companion drawer (DRAWER refactor). Fires
// when the user collapses or expands the side rail; sema
// can hook this for telemetry / soft haptic. Mode shifts
// (contextual panel changes) flow as pure data via the
// data-words-drawer-mode attr — no event for those because
// they are derived from caret movement (already an event
// elsewhere).
name: 'commit-toggle-drawer',
semantic: {
family: 'commit',
verb: 'toggle',
target: v.partRef('drawer'),
intent: 'neutral',
sequence: 'post'
}
}
],
parts: [
@ -637,6 +655,40 @@ export const wordsMorfo = {
severity: 'recommended'
}
]
},
{
// Companion drawer (right-rail). Hosts contextual panels
// that react to caret position — replaces N popovers
// (bubble menu + slash menu + link editor + table popover
// + image controls) with a single sticky surface.
//
// `data-words-drawer-mode` carries the active panel; eidos
// switches its body content per-mode. Open state is a
// boolean attr so a CSS-only collapse animation is possible.
name: 'Drawer',
kebab: 'drawer',
archetype: 'group',
kind: 'public',
defaultElement: 'aside',
role: 'complementary',
optional: true,
data: [
{
attr: 'data-words-drawer-mode',
values: ['default', 'format', 'image', 'table', 'code', 'list'],
value: v.propRef('mode'),
severity: 'optional'
},
{ attr: 'data-open', value: v.propRef('open'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.drawer|Editor companion drawer'),
severity: 'recommended'
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,47 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { WordsDrawerProvider } from '../words-provider.svelte';
import type { WordsDrawerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'words-drawer'),
open = $bindable(undefined),
defaultOpen = true,
disabled = false,
children,
child,
...restProps
}: WordsDrawerProps = $props();
// Initialise from defaultOpen when uncontrolled. After the first
// pass `open` is bound to provider state.
if (open === undefined) open = defaultOpen;
const state = WordsDrawerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
open: writableActive(
() => open ?? defaultOpen,
(v) => (open = v)
),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...state.snippetProps })}
{:else}
<aside {...mergedProps}>
{@render children?.(state.snippetProps)}
</aside>
{/if}

@ -10,6 +10,7 @@ export { default as LinkEditor } from './components/words-link-editor.svelte';
export { default as HiddenInput } from './components/words-hidden-input.svelte';
export { default as Status } from './components/words-status.svelte';
export { default as FindReplace } from './components/words-find-replace.svelte';
export { default as Drawer } from './components/words-drawer.svelte';
export type {
WordsCommandName,
@ -33,6 +34,9 @@ export type {
WordsHiddenInputProps as HiddenInputProps,
WordsStatusProps as StatusProps,
WordsFindReplaceProps as FindReplaceProps,
WordsDrawerProps as DrawerProps,
WordsDrawerMode,
WordsDrawerSnippetProps as DrawerSnippetProps,
WordsProviderSnippetProps as ProviderSnippetProps,
WordsContentSnippetProps as ContentSnippetProps,
WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps,

@ -351,6 +351,34 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> &
export type WordsStatusProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown }>;
/** Active panel of the companion drawer (DRAWER refactor). Mirrors
* `WordsDrawerMode` exported from the provider; surfaced here so eidos
* doesn't import provider internals. */
export type WordsDrawerMode = 'default' | 'format' | 'image' | 'table' | 'code' | 'list';
export type WordsDrawerSnippetProps = {
readonly mode: WordsDrawerMode;
readonly open: boolean;
readonly disabled: boolean;
readonly snippet: WordsProviderSnippetProps;
readonly toggle: (target?: HTMLElement) => boolean;
readonly setOpen: (open: boolean, target?: HTMLElement) => void;
};
export type WordsDrawerProps = WithChild<
{
id?: string;
open?: boolean;
defaultOpen?: boolean;
disabled?: boolean;
'aria-label'?: string;
children?: Snippet<[WordsDrawerSnippetProps]>;
},
WordsDrawerSnippetProps,
HTMLElement
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string; role?: unknown }>;
export type WordsFindReplaceSnippetProps = {
readonly findQuery: string;
readonly findCaseSensitive: boolean;

@ -122,6 +122,10 @@ interface WordsLinkEditorOpts
interface WordsFindReplaceOpts extends WithRefOpts {}
interface WordsHiddenInputOpts extends WithRefOpts {}
interface WordsStatusOpts extends WithRefOpts {}
interface WordsDrawerOpts
extends WithRefOpts,
StateProps<{ open: boolean }>,
ActiveProps<{ disabled: boolean }> {}
type WordsSelectionSyncSource =
| 'selectionchange'
| 'pointer'
@ -2401,6 +2405,131 @@ export class WordsStatusProvider {
);
}
/**
* Drawer — companion right-rail provider.
*
* Owns the open/collapsed state of the drawer and the derived `mode`
* (the contextual panel that should be visible: default | format |
* image | table | code | list). Eidos consumes both via the snippet
* to render the shell + the per-mode body.
*
* Mode derivation lives here (not in eidos) so any other consumer
* that wants to react to the contextual context (telemetry, future
* mini-drawer overlays, etc.) reads the same source of truth.
*/
export type WordsDrawerMode =
| 'default'
| 'format'
| 'image'
| 'table'
| 'code'
| 'list';
export class WordsDrawerProvider {
readonly opts: WordsDrawerOpts;
readonly provider: WordsProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: WordsDrawerOpts) {
return new WordsDrawerProvider(opts);
}
private constructor(opts: WordsDrawerOpts) {
this.opts = opts;
this.provider = WordsProvider.require();
this.runtimePart = this.provider.runtime.part('drawer', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
mode: () => this.mode,
open: () => this.opts.open.current,
disabled: () => this.opts.disabled.current || this.provider.isDisabled
},
syncAttrs: true
});
}
readonly mode: WordsDrawerMode = $derived.by(() => {
const sel = this.provider.selection;
// Text-selection format takes precedence over block-type panels:
// a non-collapsed selection inside an inline-text block puts the
// drawer in 'format' mode so the user can act on the selection.
if (
sel &&
(sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.offset !== sel.focus.offset)
) {
const block = this.provider.currentBlock;
if (
block === 'paragraph' ||
block === 'heading' ||
block === 'quote' ||
block === 'list'
) {
return 'format';
}
}
const block = this.provider.currentBlock;
if (block === 'image') return 'image';
if (block === 'table') return 'table';
if (block === 'code') return 'code';
if (block === 'list') return 'list';
return 'default';
});
/**
* Toggle the drawer open/closed and emit the `commit-toggle-drawer`
* event so sema / telemetry can react. Returns the new open state.
*/
toggle(target?: HTMLElement): boolean {
const next = !this.opts.open.current;
this.opts.open.current = next;
try {
void this.provider.runtime.trigger('commit-toggle-drawer', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
} catch {
/* noop — runtime may not be wired (tests / SSR) */
}
return next;
}
setOpen(open: boolean, target?: HTMLElement): void {
if (this.opts.open.current === open) return;
this.opts.open.current = open;
try {
void this.provider.runtime.trigger('commit-toggle-drawer', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
} catch {
/* noop */
}
}
readonly snippetProps = $derived.by(() => ({
mode: this.mode,
open: this.opts.open.current,
disabled: this.opts.disabled.current || this.provider.isDisabled,
// Forward the parent snippet so eidos panels can call runCommand,
// read activeMarks, etc. without re-piping every action.
snippet: this.provider.snippetProps,
toggle: (target?: HTMLElement) => this.toggle(target),
setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target)
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'complementary' as const,
'data-words-drawer-mode': this.mode,
'data-open': this.opts.open.current ? '' : undefined,
'data-disabled':
this.opts.disabled.current || this.provider.isDisabled ? '' : undefined
} as const)
);
}
function firstUsableClientRect(range: Range): DOMRect | null {
const rects = range.getClientRects();
for (const rect of rects) {

@ -1074,7 +1074,7 @@
{/if}
<Words.HiddenInput />
{#if showDrawer}
<Words.Drawer snippet={snippetProps} />
<Words.Drawer />
{/if}
{#if showStatus}
<Words.Status>

Loading…
Cancel
Save

Powered by TurnKey Linux.