feat(words): companion drawer right-rail (concept A+D) — shell + default panel (DRAWER-1)

A radical disposition shift: instead of N fragmented popovers (bubble
menu + slash menu + link editor + heading picker + language picker +
table popover + image controls) we mount ONE contextual right-rail
that changes its panel based on what the user is editing.

Spike-1 surface (this commit) ships:

- New eidos component <Words.Drawer> (words-drawer.svelte) — a
  collapsible right sidebar with mode detection derived from snippet
  props (currentBlock + selection). 6 modes mapped: default | format
  | image | table | code | list.
- Default panel (visible when no contextual mode active):
  - OUTLINE — flat list of h1/h2/h3 headings, indented by level
  - STATS — word count + char count + reading time (200wpm)
  - SUGGESTIONS — writer-coaching warnings derived from the document
    model: missing alt text, long paragraphs (>600 chars), heading
    skip patterns (h3 without h2 above). Empty state shows "Nothing
    flagged" in affirm color.
- Contextual panels (format/image/table/code/list) render a
  placeholder "coming soon" message — implemented in DRAWER-2.
- Eidos CSS:
  - Provider host gains a grid-template-areas layout when a drawer is
    present: 'toolbar drawer / content drawer / status drawer'.
    Existing children (toolbar/content/placeholder/status) get
    explicit grid-area assignments.
  - Drawer collapses to 2.25rem (just the chevron) when closed.
    16rem default open.
  - Outline items, stats grid, warning list each have their own
    discrete styling.

Demo (+page.svelte):
- New `showDrawer` state (default true).
- Children snippet refactored to capture full snippetProps via @const
  destructure (kept selectedLink working).
- <Words.Drawer snippet={snippetProps} /> mounted inside the children
  block alongside the other parts.

Visual verification:
- Drawer renders as a dedicated right column with the editor content
  on the left, no layout overlap.
- DOCUMENT header + OUTLINE shows "H1 ActiveUIX Words", STATS shows
  73 words / 508 chars / 1 min reading, SUGGESTIONS shows "Nothing
  flagged" (sample doc has no warnings).
- Collapse button toggles open/closed via animated inline-size
  transition.

Next:
- DRAWER-2: format/image/table/code/list contextual panels wired to
  runCommand.
- DRAWER-3: polish (mobile drawer-as-bottom-sheet, transitions, A/B
  toggle in demo to compare classic toolbar vs companion drawer).

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

@ -13,6 +13,7 @@ import LinkEditor from './words-link-editor.svelte';
import HiddenInput from './words-hidden-input.svelte'; import HiddenInput from './words-hidden-input.svelte';
import Status from './words-status.svelte'; import Status from './words-status.svelte';
import FindReplace from './words-find-replace.svelte'; import FindReplace from './words-find-replace.svelte';
import Drawer from './words-drawer.svelte';
type WordsNamespace = typeof WordsComponent & { type WordsNamespace = typeof WordsComponent & {
Content: typeof Content; Content: typeof Content;
@ -29,6 +30,7 @@ type WordsNamespace = typeof WordsComponent & {
HiddenInput: typeof HiddenInput; HiddenInput: typeof HiddenInput;
Status: typeof Status; Status: typeof Status;
FindReplace: typeof FindReplace; FindReplace: typeof FindReplace;
Drawer: typeof Drawer;
}; };
const Words = WordsComponent as WordsNamespace; const Words = WordsComponent as WordsNamespace;
@ -46,6 +48,7 @@ Words.LinkEditor = LinkEditor;
Words.HiddenInput = HiddenInput; Words.HiddenInput = HiddenInput;
Words.Status = Status; Words.Status = Status;
Words.FindReplace = FindReplace; Words.FindReplace = FindReplace;
Words.Drawer = Drawer;
export { Words }; export { Words };
export default Words; export default Words;

@ -0,0 +1,264 @@
<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.
*
* Inspired by Figma's right panel: the same UI slot shows different
* controls depending on what the user is editing.
*
* 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.
*/
import { ChevronRight, ChevronLeft } from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlockType
} 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();
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> = {
default: 'Document',
format: 'Format',
image: 'Image',
table: 'Table',
code: 'Code',
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;
}
</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 />
{/if}
</button>
{#if open}
<span data-words-drawer-title>{modeLabel}</span>
{/if}
</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>
<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}
<!-- Contextual panels land in DRAWER-2. -->
<section data-words-drawer-section>
<p data-words-drawer-empty>
Panel for <strong>{modeLabel}</strong> coming soon.
</p>
</section>
{/if}
</div>
{/if}
</aside>

@ -131,6 +131,243 @@
grid-template-rows: auto auto; grid-template-rows: auto auto;
} }
/* ── Companion Drawer (right-rail) ───────────────────────────────────────
Layout: when the editor host contains a [data-words-drawer], place
the drawer in a dedicated right column that spans toolbar+content+
status rows. Children without a grid-area placement (BubbleMenu /
SlashMenu / HiddenInput / find-replace etc.) are absolute-positioned
or hidden inputs that don't participate in the visual flow. */
[data-words]:has([data-words-drawer]) {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
'toolbar drawer'
'content drawer'
'status drawer';
column-gap: 0;
}
[data-words]:has([data-words-drawer]) > [data-words-toolbar] {
grid-area: toolbar;
}
[data-words]:has([data-words-drawer]) > [data-words-content],
[data-words]:has([data-words-drawer]) > [data-words-placeholder] {
grid-area: content;
}
[data-words]:has([data-words-drawer]) > [data-words-status] {
grid-area: status;
}
[data-words]:has([data-words-drawer]) > [data-words-drawer] {
grid-area: drawer;
}
[data-words-drawer] {
display: flex;
flex-direction: column;
inline-size: 14rem;
max-inline-size: 18rem;
block-size: 100%;
min-block-size: 100%;
padding: 0;
border-inline-start: var(--words-border-width) solid var(--words-toolbar-border);
background: var(--words-toolbar-bg);
color: var(--words-command-color);
font-size: var(--words-status-font-size);
overflow: hidden;
transition: inline-size 160ms ease;
}
[data-words-drawer]:not([data-open]) {
inline-size: 2.25rem;
max-inline-size: 2.25rem;
}
[data-words-drawer-header] {
display: flex;
align-items: center;
gap: var(--words-command-gap);
padding-block: var(--words-toolbar-py-sm);
padding-inline: var(--words-toolbar-px-sm);
border-block-end: var(--words-border-width) solid var(--words-toolbar-border);
background: color-mix(in srgb, var(--_words-accent-solid) 5%, transparent);
}
[data-words-drawer-collapse] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.5rem;
block-size: 1.5rem;
padding: 0;
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-command-color);
cursor: pointer;
}
[data-words-drawer-collapse]:hover {
background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
}
[data-words-drawer-title] {
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.7rem;
color: var(--words-status-color);
}
[data-words-drawer-body] {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
[data-words-drawer-section] {
padding-block: var(--words-toolbar-py-sm);
padding-inline: var(--words-toolbar-px-sm);
border-block-end: var(--words-border-width) solid
color-mix(in srgb, var(--words-toolbar-border) 60%, transparent);
}
[data-words-drawer-section]:last-child {
border-block-end: 0;
}
[data-words-drawer-section-title] {
display: flex;
align-items: baseline;
gap: var(--words-command-gap);
margin: 0 0 var(--words-toolbar-py-sm) 0;
font-size: 0.7rem;
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--words-status-color);
}
[data-words-drawer-section-count] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: 1.25rem;
padding-block: 0;
padding-inline: 0.3rem;
border-radius: 999px;
background: var(--_words-accent-solid);
color: var(--_words-accent-on);
font-size: 0.65rem;
}
[data-words-drawer-empty] {
margin: 0;
color: var(--words-status-color);
font-style: italic;
font-size: 0.8rem;
}
[data-words-drawer-empty][data-tone='affirm'] {
color: var(--words-affirm-text);
font-style: normal;
}
[data-words-drawer-outline] {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
[data-words-drawer-outline-item] {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding-inline-start: 0;
font-size: 0.8rem;
line-height: 1.3;
}
[data-words-drawer-outline-item][data-level='2'] {
padding-inline-start: 0.75rem;
}
[data-words-drawer-outline-item][data-level='3'] {
padding-inline-start: 1.5rem;
}
[data-words-drawer-outline-bullet] {
flex-shrink: 0;
min-inline-size: 1.5rem;
color: var(--_words-accent-text);
font-size: 0.65rem;
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.04em;
}
[data-words-drawer-outline-text] {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-words-drawer-stats] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 0.5rem;
margin: 0;
}
[data-words-drawer-stats] > div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8rem;
}
[data-words-drawer-stats] dt {
margin: 0;
color: var(--words-status-color);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-words-drawer-stats] dd {
margin: 0;
font-weight: var(--words-strong-font-weight);
}
[data-words-drawer-warnings] {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
[data-words-drawer-warnings] li {
padding-block: 0.35rem;
padding-inline: 0.5rem;
border-radius: var(--words-command-radius);
background: color-mix(in srgb, var(--words-toolbar-border) 30%, transparent);
font-size: 0.75rem;
line-height: 1.35;
}
[data-words-drawer-warnings] li[data-severity='warn'] {
background: color-mix(in srgb, var(--words-risk-text) 12%, transparent);
color: var(--words-risk-text);
}
[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) { [data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) {
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
} }

@ -192,6 +192,7 @@
let showBubbleMenu = $state(false); let showBubbleMenu = $state(false);
let showSlashMenu = $state(true); let showSlashMenu = $state(true);
let showStatus = $state(true); let showStatus = $state(true);
let showDrawer = $state(true);
let orientation = $state<WordsToolbarOrientation>('horizontal'); let orientation = $state<WordsToolbarOrientation>('horizontal');
let toolbarLayout = $state<'grouped' | 'inline'>('grouped'); let toolbarLayout = $state<'grouped' | 'inline'>('grouped');
let size = $state<WordsSize>('md'); let size = $state<WordsSize>('md');
@ -1039,7 +1040,8 @@
{toolbarLayout} {toolbarLayout}
onValueCommit={(_, reason) => (lastCommit = reason)} onValueCommit={(_, reason) => (lastCommit = reason)}
> >
{#snippet children({ selectedLink })} {#snippet children(snippetProps)}
{@const { selectedLink } = snippetProps}
{#if showLinkEditor && toolbarChoice === 'none'} {#if showLinkEditor && toolbarChoice === 'none'}
<Words.LinkEditor /> <Words.LinkEditor />
{/if} {/if}
@ -1071,6 +1073,9 @@
<Words.SlashMenu /> <Words.SlashMenu />
{/if} {/if}
<Words.HiddenInput /> <Words.HiddenInput />
{#if showDrawer}
<Words.Drawer snippet={snippetProps} />
{/if}
{#if showStatus} {#if showStatus}
<Words.Status> <Words.Status>
{#if invalid} {#if invalid}

Loading…
Cancel
Save

Powered by TurnKey Linux.