feat(words): drawer hierarchical mode stack — Format → Cell → Row → Table (HIER 1-3)

The drawer now renders the FULL hierarchy of scopes that apply at the
caret, instead of only the most-specific one. Inside a table cell with
text selected the user sees four panels stacked:

  Format  ← active selection's inline marks/links
  Cell    ← tone, vertical align, cell text-align
  Row     ← +/- row, row header toggle
  Table   ← striped, compact, +/- column, col header

Each panel is independently collapsible. Default: the leaf (most
specific) is open, the rest are collapsed but accessible via the
chevron next to their header. User-explicit toggles persist across
context changes.

HIER-1 — soma:
- WordsDrawerMode union expanded: split 'table' → 'cell' | 'row' |
  'table'; split 'list' → 'list-item' | 'list'; added 'block' for the
  paragraph/heading/quote text-align scope.
- WordsDrawerProvider gains `modes: readonly DrawerMode[]` derived
  that walks the caret context outward (most-specific first). The
  legacy `mode` field is kept as the top of the stack for consumers
  that just want the leaf.
- snippetProps surfaces both `modes` (stack) and `mode` (leaf).
- Morfo `data-words-drawer-mode` values updated to the new union.

HIER-2 — eidos panel split:
- Table panel decomposed into three: cell-scope (tone + vertical +
  text-align), row-scope (+/- row, row header), table-scope (+/- col,
  col header, striped, compact).
- List panel decomposed into list-item-scope (indent/outdent) +
  list-scope (kind).
- New 'block' panel hosts text-align when caret is in a heading /
  paragraph / quote outside a table cell.
- Each panel is a `<section data-words-drawer-panel data-depth=N>`
  with its own toggleable header + body snippet.

HIER-3 — per-panel collapse:
- SvelteMap stores user-explicit open/closed overrides per mode.
  isPanelOpen() falls back to "open if leaf, else collapsed" when no
  override exists.
- Drawer header now shows leaf title in bold + breadcrumb of deeper
  panels in muted text (Format · Cell · Row · Table).
- Visual depth cue: border-inline-start of each panel renders at
  decreasing strength as depth grows (leaf solid, deeper at 60% /
  30% accent border).

Visual verification: selected text inside a sample table cell shows
4 panels stacked (Format open, Cell/Row/Table collapsed), header
breadcrumb "FORMAT · Cell · Row · Table". Click on any collapsed
header expands it. 214/214 tests pass.

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

@ -2,26 +2,25 @@
/** /**
* Words.Drawer — companion right-rail eidos wrapper. * Words.Drawer — companion right-rail eidos wrapper.
* *
* After DRAWER-CANON-2 the drawer is canonical: it consumes the * After HIER-1/2/3 the drawer renders a STACK of panels from
* soma `Words.Drawer` provider (which owns the open/collapsed state * most-specific to most-general. E.g. inside a table cell with
* and the derived `mode`) and renders the per-mode body here in * text selected the user sees: Format → Cell → Row → Table —
* eidos. * each panel scoped to its level so every relevant action is
* reachable from one surface (instead of guessing which popover/
* menu owns it).
* *
* Concept (still A+D from the spike): * Modes are derived in soma (`WordsDrawerProvider.modes`); eidos
* - Single contextual surface replacing bubble menu + slash menu + * just paints them.
* 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 { ChevronRight, ChevronLeft, ChevronDown, ChevronUp } from '$uix/eidos/components/icon';
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { import type {
DrawerSnippetProps, DrawerSnippetProps,
DrawerProps, DrawerProps,
ProviderSnippetProps ProviderSnippetProps,
WordsDrawerMode
} from '$soma/components/words'; } from '$soma/components/words';
import { SvelteMap } from 'svelte/reactivity';
let { let {
open = $bindable(undefined), open = $bindable(undefined),
@ -31,15 +30,34 @@
...rest ...rest
}: Omit<DrawerProps, 'child'> = $props(); }: Omit<DrawerProps, 'child'> = $props();
const MODE_LABELS: Record<DrawerSnippetProps['mode'], string> = { const MODE_LABELS: Record<WordsDrawerMode, string> = {
default: 'Document', default: 'Document',
format: 'Format', format: 'Format',
image: 'Image', block: 'Block',
cell: 'Cell',
row: 'Row',
table: 'Table', table: 'Table',
'list-item': 'List item',
list: 'List',
code: 'Code', code: 'Code',
list: 'List' image: 'Image'
}; };
// Per-panel expand/collapse map. Default heuristic: first mode in
// the stack (most specific) is open, the rest collapsed. User
// explicit toggles override the default and persist across context
// changes.
const panelOpenOverrides = new SvelteMap<WordsDrawerMode, boolean>();
function isPanelOpen(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]): boolean {
const override = panelOpenOverrides.get(mode);
if (override !== undefined) return override;
// Default: only the top of the stack is open.
return modes[0] === mode;
}
function togglePanel(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]) {
panelOpenOverrides.set(mode, !isPanelOpen(mode, modes));
}
function countWordsInText(text: string): number { function countWordsInText(text: string): number {
const trimmed = text.trim(); const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0; return trimmed ? trimmed.split(/\s+/).length : 0;
@ -74,10 +92,7 @@
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest}> <Words.Drawer bind:open {defaultOpen} {disabled} {...rest}>
{#snippet children(d: DrawerSnippetProps)} {#snippet children(d: DrawerSnippetProps)}
{@const { mode, snippet, toggle } = d} {@const { modes, snippet, toggle: toggleDrawer } = 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> <header data-words-drawer-header>
<button <button
@ -85,7 +100,7 @@
data-words-drawer-collapse data-words-drawer-collapse
aria-label={d.open ? 'Collapse drawer' : 'Expand drawer'} aria-label={d.open ? 'Collapse drawer' : 'Expand drawer'}
aria-expanded={d.open} aria-expanded={d.open}
onclick={(e) => toggle(e.currentTarget as HTMLElement)} onclick={(e) => toggleDrawer(e.currentTarget as HTMLElement)}
> >
{#if d.open} {#if d.open}
<ChevronRight size="sm" decorative /> <ChevronRight size="sm" decorative />
@ -94,7 +109,15 @@
{/if} {/if}
</button> </button>
{#if d.open} {#if d.open}
<span data-words-drawer-title>{MODE_LABELS[mode]}</span> <span data-words-drawer-title>{MODE_LABELS[modes[0]]}</span>
{#if modes.length > 1}
<span data-words-drawer-trail aria-hidden="true">
{#each modes.slice(1) as m, i (m)}
<span data-words-drawer-trail-sep>·</span>
<span data-words-drawer-trail-item>{MODE_LABELS[m]}</span>
{/each}
</span>
{/if}
{/if} {/if}
</header> </header>
@ -102,267 +125,345 @@
<div data-words-drawer-body> <div data-words-drawer-body>
{#if customChildren} {#if customChildren}
{@render customChildren(d)} {@render customChildren(d)}
{:else if mode === 'default'} {:else}
{@const outline = snippet.document.children {#each modes as mode, i (mode)}
.map((block, blockIndex) => ({ block, blockIndex })) {@const panelOpen = isPanelOpen(mode, modes)}
.filter(({ block }) => block.type === 'heading') <section
.map(({ block, blockIndex }) => ({ data-words-drawer-panel
blockIndex, data-mode={mode}
level: block.type === 'heading' ? block.level : 1, data-open={panelOpen ? '' : undefined}
text: data-depth={i}
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>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>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}
</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>
<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 <button
type="button" type="button"
data-words-drawer-action data-words-drawer-panel-header
data-tone="full" aria-expanded={panelOpen}
onmousedown={(e) => e.preventDefault()} onclick={() => togglePanel(mode, modes)}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
> >
Add link <span data-words-drawer-panel-title>{MODE_LABELS[mode]}</span>
{#if panelOpen}
<ChevronUp size="xs" decorative />
{:else}
<ChevronDown size="xs" decorative />
{/if}
</button> </button>
{#if snippet.canUnlink} {#if panelOpen}
<button <div data-words-drawer-panel-body>
type="button" {#if mode === 'default'}
data-words-drawer-action {@render defaultPanel(snippet)}
onmousedown={(e) => e.preventDefault()} {:else if mode === 'format'}
onclick={() => snippet.unlink()} {@render formatPanel(snippet)}
> {:else if mode === 'block'}
Remove {@render blockPanel(snippet)}
</button> {:else if mode === 'cell'}
{@render cellPanel(snippet)}
{:else if mode === 'row'}
{@render rowPanel(snippet)}
{:else if mode === 'table'}
{@render tablePanel(snippet)}
{:else if mode === 'list-item'}
{@render listItemPanel(snippet)}
{:else if mode === 'list'}
{@render listPanel(snippet)}
{:else if mode === 'code'}
{@render codePanel(snippet)}
{:else if mode === 'image'}
{@render imagePanel(snippet)}
{/if}
</div>
{/if} {/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>
<section data-words-drawer-section> {/each}
<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 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} {/if}
</div> </div>
{/if} {/if}
{/snippet} {/snippet}
</Words.Drawer> </Words.Drawer>
{#snippet defaultPanel(s: ProviderSnippetProps)}
{@const wordCount = countWordsInText(s.plainText)}
{@const charCount = s.plainText.length}
{@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))}
{@const outline = s.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(s)}
<div data-words-drawer-section data-flush>
<h4 data-words-drawer-section-title>Outline</h4>
{#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}
</div>
<div data-words-drawer-section data-flush>
<h4 data-words-drawer-section-title>Stats</h4>
<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>
</div>
<div data-words-drawer-section data-flush>
<h4 data-words-drawer-section-title>
Suggestions
{#if warnings.length > 0}
<span data-words-drawer-section-count>{warnings.length}</span>
{/if}
</h4>
{#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}
</div>
{/snippet}
{#snippet formatPanel(s: ProviderSnippetProps)}
<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={s.activeMarks.includes(mark.id) ? '' : undefined}
title={mark.title}
aria-pressed={s.activeMarks.includes(mark.id)}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand(mark.id)}
>
{mark.label}
</button>
{/each}
</div>
<div data-words-drawer-actions data-flush>
<button
type="button"
data-words-drawer-action
data-tone="full"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) s.insertLink(url);
}}
>
Add link
</button>
{#if s.canUnlink}
<button
type="button"
data-words-drawer-action
onmousedown={(e) => e.preventDefault()}
onclick={() => s.unlink()}
>
Remove
</button>
{/if}
<button
type="button"
data-words-drawer-action
data-tone="ghost"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand('clear-formatting')}
>
Clear
</button>
</div>
{/snippet}
{#snippet blockPanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Text align</div>
<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={s.currentTextAlign === a.id.replace('align-', '') ? '' : undefined}
title={a.title}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand(a.id)}
>
{a.label}
</button>
{/each}
</div>
{/snippet}
{#snippet cellPanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Tone</div>
<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={s.currentTableCellTone === t.id.replace('table-cell-tone-', '') ? '' : undefined}
onclick={() => s.runCommand(t.id)}
>{t.label}</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Vertical</div>
<div data-words-drawer-actions>
{#each [
{ id: 'table-cell-vertical-top', label: '⬆ Top' },
{ id: 'table-cell-vertical-middle', label: '◆ Mid' },
{ id: 'table-cell-vertical-bottom', label: '⬇ Bot' }
] as v (v.id)}
<button
type="button"
data-words-drawer-action
data-active={s.currentTableCellVerticalAlign === v.id.replace('table-cell-vertical-', '') ? '' : undefined}
onclick={() => s.runCommand(v.id)}
>{v.label}</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Text align</div>
<div data-words-drawer-actions>
{#each [
{ id: 'align-left', label: '⫷' },
{ id: 'align-center', label: '☰' },
{ id: 'align-right', label: '⫸' },
{ id: 'align-justify', label: '☷' }
] as a (a.id)}
<button
type="button"
data-words-drawer-action
data-active={s.currentTextAlign === a.id.replace('align-', '') ? '' : undefined}
onclick={() => s.runCommand(a.id)}
>{a.label}</button>
{/each}
</div>
{/snippet}
{#snippet rowPanel(s: ProviderSnippetProps)}
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('insert-table-row')}>+ Row</button>
<button type="button" data-words-drawer-action data-tone="risk" onclick={() => s.runCommand('delete-table-row')}>− Row</button>
<button
type="button"
data-words-drawer-action
data-active={s.currentTableHeaderRowActive ? '' : undefined}
onclick={() => s.runCommand('toggle-table-header-row')}
>Header</button>
</div>
{/snippet}
{#snippet tablePanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Columns</div>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('insert-table-column')}>+ Col</button>
<button type="button" data-words-drawer-action data-tone="risk" onclick={() => s.runCommand('delete-table-column')}>− Col</button>
<button
type="button"
data-words-drawer-action
data-active={s.currentTableHeaderColumnActive ? '' : undefined}
onclick={() => s.runCommand('toggle-table-header-column')}
>Header</button>
</div>
<div data-words-drawer-section-subtitle>Appearance</div>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={s.currentTableStriped ? '' : undefined}
onclick={() => s.runCommand('toggle-table-striped')}
>Striped</button>
<button
type="button"
data-words-drawer-action
data-active={s.currentTableCompact ? '' : undefined}
onclick={() => s.runCommand('toggle-table-compact')}
>Compact</button>
</div>
{/snippet}
{#snippet listItemPanel(s: ProviderSnippetProps)}
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('decrease-indent')}>⇤ Outdent</button>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('increase-indent')}>⇥ Indent</button>
</div>
{/snippet}
{#snippet listPanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Kind</div>
<div data-words-drawer-actions>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('unordered-list')}>Bullet</button>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('ordered-list')}>Numbered</button>
<button type="button" data-words-drawer-action onclick={() => s.runCommand('check-list')}>Check</button>
</div>
{/snippet}
{#snippet codePanel(s: ProviderSnippetProps)}
<div data-words-drawer-actions>
{#each CODE_LANGUAGES as lang (lang.id)}
<button
type="button"
data-words-drawer-action
data-active={s.currentCodeLanguage === lang.id ? '' : undefined}
onclick={() => s.setCodeLanguage(lang.id || undefined)}
>{lang.label}</button>
{/each}
</div>
{/snippet}
{#snippet imagePanel(s: ProviderSnippetProps)}
{@const block = imageBlockAtCaret(s)}
{#if block}
<div data-words-drawer-section-subtitle>Source</div>
<p data-words-drawer-readonly-text>{block.src}</p>
<div data-words-drawer-section-subtitle>Alt</div>
<p data-words-drawer-readonly-text data-empty={!block.alt}>
{block.alt || '(missing)'}
</p>
{#if block.caption}
<div data-words-drawer-section-subtitle>Caption</div>
<p data-words-drawer-readonly-text>{block.caption}</p>
{/if}
{#if block.status}
<div data-words-drawer-section-subtitle>Status</div>
<p data-words-drawer-readonly-text data-tone={block.status}>
{block.status === 'pending' ? 'Uploading…' : 'Upload failed'}
</p>
{/if}
{:else}
<p data-words-drawer-empty>Place caret on an image to edit it.</p>
{/if}
{/snippet}
<script context="module" lang="ts"> <script context="module" lang="ts">
import type { ProviderSnippetProps as PSP } from '$soma/components/words'; import type { ProviderSnippetProps as PSP } from '$soma/components/words';
type Warning = { type Warning = {

@ -239,6 +239,115 @@
border-block-end: 0; border-block-end: 0;
} }
[data-words-drawer-section][data-flush]:not(:last-child) {
margin-block-end: var(--words-toolbar-py-sm);
}
[data-words-drawer-section][data-flush]:first-child {
padding-block-start: 0;
}
/* Hierarchical panels (HIER) — each scope in the modes[] stack is a
collapsible <section> with its own header + body. Default heuristic:
the topmost (most-specific) panel is open, the rest collapsed but
accessible by tapping the header chevron. */
[data-words-drawer-panel] {
display: flex;
flex-direction: column;
border-block-end: var(--words-border-width) solid
color-mix(in srgb, var(--words-toolbar-border) 60%, transparent);
}
[data-words-drawer-panel]:last-child {
border-block-end: 0;
}
[data-words-drawer-panel-header] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--words-command-gap);
padding-block: 0.35rem;
padding-inline: var(--words-toolbar-px-sm);
border: 0;
background: transparent;
color: var(--words-status-color);
font-size: 0.7rem;
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.04em;
text-transform: uppercase;
text-align: start;
cursor: pointer;
}
[data-words-drawer-panel-header]:hover {
background: color-mix(in srgb, var(--_words-accent-solid) 5%, transparent);
color: var(--_words-accent-text);
}
[data-words-drawer-panel][data-open] [data-words-drawer-panel-header] {
background: color-mix(in srgb, var(--_words-accent-solid) 4%, transparent);
color: var(--words-command-color);
}
/* Depth indicator — the leaf panel is full-strength; deeper-in-the-
hierarchy panels render slightly muted so the user can read the
precedence visually. */
[data-words-drawer-panel][data-depth='0'] {
border-inline-start: 2px solid var(--_words-accent-border);
}
[data-words-drawer-panel][data-depth='1'] {
border-inline-start: 2px solid
color-mix(in srgb, var(--_words-accent-border) 60%, transparent);
}
[data-words-drawer-panel][data-depth='2'],
[data-words-drawer-panel][data-depth='3'] {
border-inline-start: 2px solid
color-mix(in srgb, var(--_words-accent-border) 30%, transparent);
}
[data-words-drawer-panel-title] {
flex: 1;
}
[data-words-drawer-panel-body] {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-block-end: var(--words-toolbar-py-sm);
padding-inline: var(--words-toolbar-px-sm);
}
[data-words-drawer-section-subtitle] {
color: var(--words-status-color);
font-size: 0.65rem;
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Drawer header trail — shows the breadcrumb of stacked modes after
the leaf so the user knows what scopes are stacked underneath. */
[data-words-drawer-trail] {
display: inline-flex;
gap: 0.2rem;
margin-inline-start: 0.4rem;
color: var(--words-status-color);
font-size: 0.65rem;
font-weight: normal;
letter-spacing: 0;
text-transform: none;
}
[data-words-drawer-trail-sep] {
opacity: 0.5;
}
[data-words-drawer-actions][data-flush] {
margin-block-start: 0.4rem;
}
[data-words-drawer-section-title] { [data-words-drawer-section-title] {
display: flex; display: flex;
align-items: baseline; align-items: baseline;

@ -674,8 +674,23 @@ export const wordsMorfo = {
optional: true, optional: true,
data: [ data: [
{ {
// Top of the panel stack (most-specific scope). The full
// stack is exposed via the snippet's `modes` array; this
// attr surfaces just the leaf so CSS hooks can style by
// the most-specific scope without parsing a list.
attr: 'data-words-drawer-mode', attr: 'data-words-drawer-mode',
values: ['default', 'format', 'image', 'table', 'code', 'list'], values: [
'default',
'format',
'block',
'cell',
'row',
'table',
'list-item',
'list',
'code',
'image'
],
value: v.propRef('mode'), value: v.propRef('mode'),
severity: 'optional' severity: 'optional'
}, },

@ -351,12 +351,31 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> &
export type WordsStatusProps = WithChild<{ id?: string }> & export type WordsStatusProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown }>; 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 * Active panel of the companion drawer.
* doesn't import provider internals. */ *
export type WordsDrawerMode = 'default' | 'format' | 'image' | 'table' | 'code' | 'list'; * The drawer renders a STACK of panels from most-specific to most-
* general (e.g. inside a table cell with text selected:
* [format, cell, row, table]). 'default' is the document-level panel
* shown when no contextual scope is active.
*/
export type WordsDrawerMode =
| 'default'
| 'format'
| 'block' // text-align scope of a paragraph/heading/quote
| 'cell' // single cell scope (tone, vertical align, header)
| 'row' // row scope (insert/delete, row header)
| 'table' // table scope (striped, compact)
| 'list-item' // single list item (indent, check)
| 'list' // list scope (kind)
| 'code' // code block (language)
| 'image'; // image block (alt/caption/align)
export type WordsDrawerSnippetProps = { export type WordsDrawerSnippetProps = {
/** Hierarchical stack of active scopes, most-specific first. */
readonly modes: readonly WordsDrawerMode[];
/** Top of the stack (most specific). Convenience for consumers
* that don't render the full stack. */
readonly mode: WordsDrawerMode; readonly mode: WordsDrawerMode;
readonly open: boolean; readonly open: boolean;
readonly disabled: boolean; readonly disabled: boolean;

@ -2420,10 +2420,14 @@ export class WordsStatusProvider {
export type WordsDrawerMode = export type WordsDrawerMode =
| 'default' | 'default'
| 'format' | 'format'
| 'image' | 'block'
| 'cell'
| 'row'
| 'table' | 'table'
| 'list-item'
| 'list'
| 'code' | 'code'
| 'list'; | 'image';
export class WordsDrawerProvider { export class WordsDrawerProvider {
readonly opts: WordsDrawerOpts; readonly opts: WordsDrawerOpts;
@ -2450,34 +2454,61 @@ export class WordsDrawerProvider {
}); });
} }
readonly mode: WordsDrawerMode = $derived.by(() => { /**
* Hierarchical panel stack (most specific first). Built per
* caret position so the drawer can render all relevant scopes
* stacked — e.g. inside a table cell with text selected:
* [format, cell, row, table]
* The user can see + act on every layer in one surface instead
* of guessing which popover/menu owns the action they need.
*/
readonly modes: readonly WordsDrawerMode[] = $derived.by(() => {
const sel = this.provider.selection; const sel = this.provider.selection;
// Text-selection format takes precedence over block-type panels: const block = this.provider.currentBlock;
// a non-collapsed selection inside an inline-text block puts the
// drawer in 'format' mode so the user can act on the selection. // Atomic blocks (no inline format, no parent scopes)
if (block === 'image') return ['image'];
if (block === 'code') return ['code'];
if (!sel) return ['default'];
const stack: WordsDrawerMode[] = [];
// Inline-text format is the most specific scope. Only meaningful
// when there's a non-collapsed selection AND the current block
// can host inline marks (excludes 'code' which is plain text).
const isRange =
sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.path[1] !== sel.focus.path[1] ||
sel.anchor.path[2] !== sel.focus.path[2] ||
sel.anchor.offset !== sel.focus.offset;
if ( if (
sel && isRange &&
(sel.anchor.path[0] !== sel.focus.path[0] || (block === 'paragraph' ||
sel.anchor.offset !== sel.focus.offset)
) {
const block = this.provider.currentBlock;
if (
block === 'paragraph' ||
block === 'heading' || block === 'heading' ||
block === 'quote' || block === 'quote' ||
block === 'list' block === 'list' ||
) { block === 'table')
return 'format'; ) {
} stack.push('format');
} }
const block = this.provider.currentBlock;
if (block === 'image') return 'image'; // Block-scope stack — from innermost container outward.
if (block === 'table') return 'table'; if (block === 'table') {
if (block === 'code') return 'code'; stack.push('cell', 'row', 'table');
if (block === 'list') return 'list'; } else if (block === 'list') {
return 'default'; stack.push('list-item', 'list');
} else if (block === 'paragraph' || block === 'heading' || block === 'quote') {
stack.push('block');
}
return stack.length > 0 ? stack : ['default'];
}); });
/** Top of the stack — convenience for consumers that just want the
* most specific active scope. */
readonly mode: WordsDrawerMode = $derived.by(() => this.modes[0] ?? 'default');
/** /**
* Toggle the drawer open/closed and emit the `commit-toggle-drawer` * Toggle the drawer open/closed and emit the `commit-toggle-drawer`
* event so sema / telemetry can react. Returns the new open state. * event so sema / telemetry can react. Returns the new open state.
@ -2508,6 +2539,7 @@ export class WordsDrawerProvider {
} }
readonly snippetProps = $derived.by(() => ({ readonly snippetProps = $derived.by(() => ({
modes: this.modes,
mode: this.mode, mode: this.mode,
open: this.opts.open.current, open: this.opts.open.current,
disabled: this.opts.disabled.current || this.provider.isDisabled, disabled: this.opts.disabled.current || this.provider.isDisabled,

Loading…
Cancel
Save

Powered by TurnKey Linux.