From 23f90a775c469debbcb0101bd2ee7ddf50c3b90d Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 20:48:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(words):=20companion=20drawer=20right-rail?= =?UTF-8?q?=20(concept=20A+D)=20=E2=80=94=20shell=20+=20default=20panel=20?= =?UTF-8?q?(DRAWER-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.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). - 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) --- src/uix/eidos/components/words/index.ts | 3 + .../components/words/words-drawer.svelte | 264 ++++++++++++++++++ src/uix/eidos/components/words/words.css | 237 ++++++++++++++++ web/routes/uix/components/words/+page.svelte | 7 +- 4 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/components/words/words-drawer.svelte diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index 4fdf82726..97db25cd6 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -13,6 +13,7 @@ import LinkEditor from './words-link-editor.svelte'; import HiddenInput from './words-hidden-input.svelte'; import Status from './words-status.svelte'; import FindReplace from './words-find-replace.svelte'; +import Drawer from './words-drawer.svelte'; type WordsNamespace = typeof WordsComponent & { Content: typeof Content; @@ -29,6 +30,7 @@ type WordsNamespace = typeof WordsComponent & { HiddenInput: typeof HiddenInput; Status: typeof Status; FindReplace: typeof FindReplace; + Drawer: typeof Drawer; }; const Words = WordsComponent as WordsNamespace; @@ -46,6 +48,7 @@ Words.LinkEditor = LinkEditor; Words.HiddenInput = HiddenInput; Words.Status = Status; Words.FindReplace = FindReplace; +Words.Drawer = Drawer; export { Words }; export default Words; diff --git a/src/uix/eidos/components/words/words-drawer.svelte b/src/uix/eidos/components/words/words-drawer.svelte new file mode 100644 index 000000000..5ec6b8321 --- /dev/null +++ b/src/uix/eidos/components/words/words-drawer.svelte @@ -0,0 +1,264 @@ + + + diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index ba073b759..b2ffe7252 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -131,6 +131,243 @@ 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]) { grid-template-rows: auto auto auto; } diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index ea7133b90..fd961904c 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -192,6 +192,7 @@ let showBubbleMenu = $state(false); let showSlashMenu = $state(true); let showStatus = $state(true); + let showDrawer = $state(true); let orientation = $state('horizontal'); let toolbarLayout = $state<'grouped' | 'inline'>('grouped'); let size = $state('md'); @@ -1039,7 +1040,8 @@ {toolbarLayout} onValueCommit={(_, reason) => (lastCommit = reason)} > - {#snippet children({ selectedLink })} + {#snippet children(snippetProps)} + {@const { selectedLink } = snippetProps} {#if showLinkEditor && toolbarChoice === 'none'} {/if} @@ -1071,6 +1073,9 @@ {/if} + {#if showDrawer} + + {/if} {#if showStatus} {#if invalid}