From 6bcbd8a983945978168498e83903acf94a4f792b Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 28 May 2026 00:31:50 +0200 Subject: [PATCH] refactor(words): rail is now a real column outside the editor box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback was right — the previous approach put the rail INSIDE the editor as extra content padding, so it was glued to the editor card with no visual separation. Reframed as a proper SIBLING column in the grid layout, with its own grey/dotted background and a gap to the editor mirroring the drawer's gap on the other side. Layout (right-drawer default): [rail 2rem] [gap 0.5rem] [editor card] [drawer] Left-drawer flips both rail + gap to the right side. CSS changes: - New tokens: `--_words-rail-px` (2rem ~32px column width), `--_words-rail-gap` (0.5rem ~8px gap to editor), `--_words-rail-bg` (subtle grey using toolbar-border tint), `--_words-rail-dot` (denser status-color dots at 65% opacity). - Grid `template-columns` and `template-areas` of `[data-words]:has([data-words-drawer])` extended to include `rail` and a `.` gap column. Left-drawer mirror reverses them. - Rail visual: `[data-words]:has(...)::before` with `grid-area: rail`, dotted radial-gradient on grey bg, rounded corners matching the editor's radius. - Reverted content + placeholder to symmetric `padding-inline` (the rail no longer lives in content's padding). Deleted the obsolete `[data-words-content]::before` rule. Overlay positioning (JS): - `words-block-handle.svelte` reads `--_words-rail-px`, `--_words-rail-gap`, `--_words-content-px` from the editor host and computes the grip's `left` as the center of the rail column in viewport coords: right-drawer: `block.rect.left - content-px - rail-gap - rail-px/2` left-drawer: `block.rect.right + content-px + rail-gap + rail-px/2` Then offset by half the grip's width (14 px) to center on the rail. Inserter "+" button (CSS-only): - `left` switched from a hard `-28px` to a calc that mirrors the grip math: `-(content-px + rail-gap + rail-px/2) - 12px` Right-edge mirror for the left-drawer case. Grip visibility bumped: - Color from `status-color` to `command-color` so it reads as active-text against the dotted rail. - Default opacity 0.45 → 0.75 (visible without hover). - Hover state gains a toolbar-bg fill + accent-text + accent border ring so the hovered grip stands out from the rail. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/words-block-handle.svelte | 42 ++++- src/uix/eidos/components/words/words.css | 155 +++++++++--------- 2 files changed, 113 insertions(+), 84 deletions(-) diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index a7e5dd8be..729a6af21 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -49,6 +49,28 @@ return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right'; } + /** + * Read the editor host's CSS variables so the grip lands centered + * inside the dedicated rail column. Layout (right-drawer default): + * [rail-px] [rail-gap] [content...] [drawer] + * Block's `rect.left` is the editor card's left + content-px. The + * rail column sits to the LEFT of the editor card by + * (rail-px + rail-gap), so the rail's center in viewport coords + * is `block.rect.left - content-px - rail-gap - rail-px/2`. + */ + function rememberRailMetrics(el: HTMLElement): { + railPx: number; + railGap: number; + contentPx: number; + } { + const host = el.closest('[data-words]') ?? document.documentElement; + const cs = getComputedStyle(host); + const railPx = parseFloat(cs.getPropertyValue('--_words-rail-px')) || 32; + const railGap = parseFloat(cs.getPropertyValue('--_words-rail-gap')) || 8; + const contentPx = parseFloat(cs.getPropertyValue('--_words-content-px')) || 16; + return { railPx, railGap, contentPx }; + } + function measureFromEl(el: HTMLElement | null) { if (!el) { position = null; @@ -56,13 +78,21 @@ } const rect = el.getBoundingClientRect(); const side = detectDrawerSide(); - // Grip is 28×28 (`1.75rem`) so the centering offset is 14. + const { railPx, railGap, contentPx } = rememberRailMetrics(el); + // Grip is 28×28 (`1.75rem`) → centering offset is 14. + const gripHalf = 14; + // Center of the rail column (relative to block's text-left): + // right-drawer: rail is BEFORE the editor box. + // rail center = block.rect.left - contentPx - railGap - railPx/2 + // left-drawer: rail is AFTER the editor box. + // rail center = block.rect.right + contentPx + railGap + railPx/2 + const railCenter = + side === 'left' + ? rect.right + contentPx + railGap + railPx / 2 + : rect.left - contentPx - railGap - railPx / 2; position = { - top: rect.top + rect.height / 2 - 14, - // Drawer on RIGHT → grip on block LEFT (left edge − 32, small - // gap so it doesn't visually overlap text descenders). - // Drawer on LEFT → grip on block RIGHT (right edge + 4). - left: side === 'left' ? rect.right + 4 : rect.left - 32 + top: rect.top + rect.height / 2 - gripHalf, + left: railCenter - gripHalf }; } diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index df2213008..4214d1ced 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -5,12 +5,14 @@ --_words-content-py: var(--words-content-py-md); --_words-content-min-block-size: var(--words-content-min-block-size-md); /* Gutter reserved for the block-handle grip + inserter "+" overlay. - Content is padded by this much on the side OPPOSITE to the drawer - so the floating overlays have a visible band to land in. 1rem - (≈16px) is the minimum: combined with the existing content-px - (16px) it gives a 32px band — wide enough for the 28px grip to - start at the editor edge and leave 4px before the text. */ - --_words-rail-px: 1rem; + Rendered as a SEPARATE column outside the editor box (its own + background + dot pattern), with a small gap before the editor. + The grip + "+" are positioned by JS at viewport coordinates that + land inside this column. */ + --_words-rail-px: 2rem; + --_words-rail-gap: 0.5rem; + --_words-rail-bg: color-mix(in srgb, var(--words-toolbar-border) 35%, transparent); + --_words-rail-dot: color-mix(in srgb, var(--words-status-color) 65%, transparent); --_words-toolbar-px: var(--words-toolbar-px-md); --_words-toolbar-py: var(--words-toolbar-py-md); --_words-button-size: var(--words-button-size-md); @@ -132,35 +134,62 @@ opacity: var(--words-disabled-opacity); } -/* ── Companion Drawer ──────────────────────────────────────────────────── - Layout: when the editor host contains a [data-words-drawer], place - the drawer in a dedicated column that spans toolbar+content+ - status rows. Default side is right; `data-side='left'` on the drawer - flips the column order. 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. */ +/* ── Layout ────────────────────────────────────────────────────────────── + The editor host is a grid: rail | gap | (toolbar/content/status) | + drawer. The rail is a dedicated column OUTSIDE the editor card that + visually holds the block-handle grip + inserter "+" overlays — with + its own grey background and dotted pattern, separated from the + editor by a small gap mirroring the drawer's gap on the other side. + When `` the columns flip. Children without + a grid-area placement (BubbleMenu / SlashMenu / HiddenInput / + find-replace) are absolute-positioned and don't participate. */ [data-words]:has([data-words-drawer]) { display: grid; - grid-template-columns: minmax(0, 1fr) max-content; + grid-template-columns: + var(--_words-rail-px) + var(--_words-rail-gap) + minmax(0, 1fr) + max-content; grid-template-rows: auto minmax(0, 1fr) auto; grid-template-areas: - 'toolbar drawer' - 'content drawer' - 'status drawer'; + 'rail . toolbar drawer' + 'rail . content drawer' + 'rail . status drawer'; column-gap: 0; } -/* Left-side drawer — flip the column order so the drawer takes the - start column and the editor flow takes the rest. The drawer's - own border-inline-start (toward the content) is what visually - separates them; we swap it to border-inline-end below. */ +/* Left-side drawer — drawer first, then rail at the END mirroring + the right-side layout (rail is on the SAME side as the grip, + opposite to the drawer). */ [data-words]:has([data-words-drawer][data-side='left']) { - grid-template-columns: max-content minmax(0, 1fr); + grid-template-columns: + max-content + minmax(0, 1fr) + var(--_words-rail-gap) + var(--_words-rail-px); grid-template-areas: - 'drawer toolbar' - 'drawer content' - 'drawer status'; + 'drawer toolbar . rail' + 'drawer content . rail' + 'drawer status . rail'; +} + +/* Rail visual — pseudo-element on the editor host that fills the + `rail` grid area with the dotted grey band. The grip + "+" overlays + are JS-positioned via viewport coordinates that land here. */ +[data-words]:has([data-words-drawer])::before { + content: ''; + grid-area: rail; + background-color: var(--_words-rail-bg); + background-image: radial-gradient( + circle 1px, + var(--_words-rail-dot) 99%, + transparent 100% + ); + background-size: 5px 5px; + background-position: 50% 50%; + background-repeat: repeat; + border-radius: var(--_words-radius); + pointer-events: none; } [data-words]:has([data-words-drawer]) > [data-words-toolbar] { @@ -769,9 +798,10 @@ border: 0; border-radius: var(--words-command-radius); background: transparent; - color: var(--words-status-color); + /* Distinct color against the dotted rail. Accent on hover. */ + color: var(--words-command-color); cursor: grab; - opacity: 0.45; + opacity: 0.75; transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; z-index: var(--z-index-popover); } @@ -779,8 +809,9 @@ [data-words-block-handle]:hover, [data-words-block-handle][data-open] { opacity: 1; - background: var(--words-command-hover-bg); - color: var(--words-command-active-color); + background: var(--words-toolbar-bg); + color: var(--_words-accent-text); + box-shadow: 0 0 0 var(--words-border-width) var(--_words-accent-border); } [data-words-block-handle]:active { @@ -853,7 +884,14 @@ [data-words-block-inserter-button] { pointer-events: auto; position: absolute; - left: -28px; + /* Land the "+" centered in the dedicated rail column to the left + of the editor box. Same metrics as the block-handle's positioning: + block.rect.left − content-px − rail-gap − rail-px/2 − 12 (button half). + The inserter wrapper sits at block.rect.left, so we offset by + −(content-px + rail-gap + rail-px/2 + 12). */ + left: calc( + -1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px + ); display: inline-flex; align-items: center; justify-content: center; @@ -878,12 +916,14 @@ color: var(--_words-accent-on); } -/* When the drawer is on the LEFT side, flip the "+" button to the - block's right margin so it doesn't clash with the drawer's - gutter (mirrors the block-handle grip behaviour). */ +/* When the drawer is on the LEFT side, flip the "+" button so it + lands in the rail column on the editor's RIGHT side. Same metric + math as the right-drawer case, just on the inline-end side. */ [data-words-block-inserter][data-side='left'] [data-words-block-inserter-button] { left: auto; - right: -28px; + right: calc( + -1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px + ); } [data-words-block-inserter-line] { @@ -1766,9 +1806,7 @@ [data-words-placeholder] { display: block; padding-block-start: var(--_words-content-py); - /* Default (drawer on right): rail on the LEFT for the grip. */ - padding-inline-start: calc(var(--_words-content-px) + var(--_words-rail-px)); - padding-inline-end: var(--_words-content-px); + padding-inline: var(--_words-content-px); color: var(--words-placeholder-color); pointer-events: none; } @@ -1779,12 +1817,9 @@ } [data-words-content] { - position: relative; min-block-size: var(--_words-content-min-block-size); padding-block: var(--_words-content-py); - /* Default (drawer on right): rail on the LEFT for the grip + "+". */ - padding-inline-start: calc(var(--_words-content-px) + var(--_words-rail-px)); - padding-inline-end: var(--_words-content-px); + padding-inline: var(--_words-content-px); color: var(--words-content-color); outline: none; overflow-wrap: anywhere; @@ -1794,42 +1829,6 @@ -webkit-user-select: text; } -/* Dotted band — fills the rail's padding zone so the user reads it as - a dedicated gutter (where grip + "+" overlays live) instead of empty - content padding. Pointer-events disabled so it doesn't intercept - clicks/selection. Pseudo-elements on a contenteditable don't affect - text selection or caret in modern browsers. */ -[data-words-content]::before { - content: ''; - position: absolute; - inset-block: 0; - inset-inline-start: 0; - inline-size: calc(var(--_words-rail-px) + var(--_words-content-px)); - background-image: radial-gradient( - circle 1px, - color-mix(in srgb, var(--words-status-color) 40%, transparent) 99%, - transparent 100% - ); - background-size: 8px 8px; - background-position: 50% 50%; - background-repeat: repeat; - pointer-events: none; - z-index: 0; -} - -[data-words]:has([data-words-drawer][data-side='left']) > [data-words-content]::before { - inset-inline-start: auto; - inset-inline-end: 0; -} - -/* When the drawer lives on the LEFT, mirror: rail moves to the RIGHT - so the grip + "+" don't fight the drawer's gutter. */ -[data-words]:has([data-words-drawer][data-side='left']) > [data-words-content], -[data-words]:has([data-words-drawer][data-side='left']) > [data-words-placeholder] { - padding-inline-start: var(--_words-content-px); - padding-inline-end: calc(var(--_words-content-px) + var(--_words-rail-px)); -} - [data-words-content] * { user-select: text; -webkit-user-select: text;