fix(words): gutter redesign — rail out, handle at block lower-left, "+" at block bottom-center

Per spec: "la banda de la derecha la eliminamos, el drag aparece con
el hover del block a la izquierda inferior y el insertar (+) en el
centro cuando el hover este en el final del block, la line debe de
ser mas solida sin blur"

Layout:
- Removed the dedicated rail grid column entirely. The editor host
  grid drops from 4 columns (rail | gap | content | drawer) to 2
  columns (content | drawer), or (drawer | content) when side=left.
- Removed `--_words-rail-bg/border/px/gap` CSS vars + the
  `::before` pseudo-element that drew the silver band. The rail
  recipe tokens (words.rail-bg / rail-border) become dead and can
  be removed in a later sweep; left for now to avoid generated-css
  test regen in the same commit.

Block handle (drag grip):
- Position math simplified: lives at the BLOCK'S lower-left corner
  with a 4px inset. `top = rect.bottom - 28 - 4`,
  `left = rect.left + 4`. No more rail-relative math, no
  detectDrawerSide / cssToPx / rememberRailMetrics helpers — all
  removed.
- CSS base opacity bumped from 0.75 → 1 (the grip is shown via
  JS only on block hover; the visibility is binary, not faded).

Block inserter ("+"):
- findSeamForCursor rewritten: only triggers when the cursor is
  INSIDE a top-level block AND in its bottom portion (last 25% of
  block height, capped at 24px). When triggered, the seam anchors
  at the block's bottom edge spanning the block's full width — CSS
  centers the button via left:50% + translateX(-50%).
- Removed listBlockBoundaries + SEAM_RANGE_PX (dead code under the
  new model — we don't compute inter-block seams anymore, we react
  to the hovered block's bottom).
- Removed drawerSide flip + data-side attr (the "+" lives INSIDE
  the block now, not in a side gutter — drawer side is irrelevant).

Line visual:
- Removed transparency: `background: var(--_words-accent-solid)`
  (was color-mix(... 35%, transparent)). Opacity 0.9 always when
  shown (no opacity-0 default + hover transition).
- Removed box-shadow halo on drop-mode state (was the blur effect
  the spec calls out). Drop mode still grows the line block-size
  from 2px to 3px so the drop target is unambiguous.

Verification: DOM probe at /uix/components/words confirms:
- Hover middle of block: handle visible at lower-left
  (top=666 vs rect.bottom=698, left=66 vs rect.left=62), inserter
  hidden.
- Hover bottom of block: handle still at lower-left, inserter
  visible spanning block width (left=block.left, width=block.width),
  CSS centers the button horizontally.
- `npm run check`: 0 errors.

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

@ -42,72 +42,27 @@
});
/**
* Detect drawer side so the grip auto-flips to the OPPOSITE margin.
* Drawer on right (default) → grip on block's left. Drawer on left
* → grip on block's right (so it doesn't fight the drawer's gutter).
* Read from the DOM each measure — cheap, and reactive to layout
* changes (user toggling side prop).
* Position the grip at the BLOCK's lower-left corner. Per the
* gutter redesign: no separate rail column; the handle overlays
* the block directly, hovering at the lower-left corner with a
* small inset so it doesn't clip the block's border.
*/
function detectDrawerSide(): 'left' | 'right' {
const drawer = document.querySelector('[data-words-drawer]');
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 cssToPx(raw: string, rootFontSize: number): number {
const trimmed = raw.trim();
const num = parseFloat(trimmed);
if (!isFinite(num)) return 0;
if (trimmed.endsWith('rem')) return num * rootFontSize;
if (trimmed.endsWith('em')) return num * rootFontSize;
// 'px', unitless, calc()-resolved → already in px (or treat as px)
return num;
}
function rememberRailMetrics(el: HTMLElement): {
railPx: number;
railGap: number;
contentPx: number;
} {
const host = el.closest<HTMLElement>('[data-words]') ?? document.documentElement;
const cs = getComputedStyle(host);
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const railPx = cssToPx(cs.getPropertyValue('--_words-rail-px'), rootFontSize) || 32;
const railGap = cssToPx(cs.getPropertyValue('--_words-rail-gap'), rootFontSize) || 8;
const contentPx = cssToPx(cs.getPropertyValue('--_words-content-px'), rootFontSize) || 16;
return { railPx, railGap, contentPx };
}
function measureFromEl(el: HTMLElement | null) {
if (!el) {
position = null;
return;
}
const rect = el.getBoundingClientRect();
const side = detectDrawerSide();
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;
// Grip is 28×28 — small inset (4px) from the block's bottom-
// left so it stays inside the block's hit area without
// touching the border. For very tall blocks the grip lives at
// the bottom-left; for very short blocks the grip extends a
// few pixels above the bottom — still readable.
const inset = 4;
const gripSize = 28;
position = {
top: rect.top + rect.height / 2 - gripHalf,
left: railCenter - gripHalf
top: rect.bottom - gripSize - inset,
left: rect.left + inset
};
}

@ -33,92 +33,41 @@
readonly right: number;
};
// Seam under the cursor (snapped to the closest one within
// `SEAM_RANGE_PX`). null while the cursor is too far from any seam.
// Seam under the cursor. Set when the cursor is in the bottom
// portion of a top-level block; cleared otherwise.
let seam = $state<Seam | null>(null);
const SEAM_RANGE_PX = 18;
function listBlockBoundaries(content: HTMLElement): Seam[] {
// Top-level blocks only — same predicate as the block-handle.
const blocks = Array.from(
content.querySelectorAll<HTMLElement>(
'[data-words-node="block"][data-words-path], [data-words-node="list"][data-words-path], [data-words-node="table"][data-words-path]'
)
).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.'));
if (!blocks.length) return [];
const seams: Seam[] = [];
// "Before first block" seam — pinned to the TOP edge of block 0.
const first = blocks[0].getBoundingClientRect();
seams.push({ insertIndex: 0, y: first.top, left: first.left, right: first.right });
// Between-blocks seams: pinned to the BOTTOM EDGE of the
// preceding block (not the midpoint of the gap). Per spec:
// "el botón de añadir bloque debe aparecer en el límite
// inferior del área en relación al bloque". The "+" reads as
// "insert AFTER this block" instead of floating in nowhere.
for (let i = 0; i < blocks.length - 1; i++) {
const a = blocks[i].getBoundingClientRect();
const b = blocks[i + 1].getBoundingClientRect();
seams.push({
insertIndex: i + 1,
y: a.bottom,
left: Math.min(a.left, b.left),
right: Math.max(a.right, b.right)
});
}
// "After last block" seam — pinned to the BOTTOM edge of the
// last block.
const last = blocks[blocks.length - 1].getBoundingClientRect();
seams.push({
insertIndex: blocks.length,
y: last.bottom,
left: last.left,
right: last.right
});
return seams;
}
/**
* The "+" only shows up when the cursor is OUTSIDE the content box
* (i.e. in the rail column) AND vertically positioned in a gap
* between two blocks. Hovering over a block — either on its text
* or on the rail aligned with the block — surfaces the grip
* instead (block-handle is the actor there). Per user spec:
* "el + solo aparece en la banda entre bloques".
* Per the gutter redesign: the "+" overlays the block ITSELF when
* the cursor is inside the block and in its BOTTOM portion (last
* ~25% of block height, capped at 24px). The seam anchors at the
* block's bottom edge with the wrapper spanning the block's width
* so the "+" button lands at horizontal center. Hovering the
* upper / middle of a block surfaces nothing — that's the
* editing area. Hovering its bottom edge surfaces the "+".
*/
function findSeamForCursor(e: MouseEvent, content: HTMLElement): Seam | null {
const contentRect = content.getBoundingClientRect();
// Cursor inside the content's horizontal extent → user is reading
// text or is in the editor body, not the rail. No "+".
if (e.clientX >= contentRect.left && e.clientX <= contentRect.right) {
return null;
}
// Cursor inside ANY block's vertical band → grip's territory.
// Half-open interval [top, bottom): the EXACT bottom edge belongs
// to the seam below (per spec — "+" anchors to the block's bottom
// edge), so the seam can win when the cursor sits on that pixel.
const blocks = Array.from(
content.querySelectorAll<HTMLElement>(
'[data-words-node="block"][data-words-path], [data-words-node="list"][data-words-path], [data-words-node="table"][data-words-path]'
)
).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.'));
for (const block of blocks) {
const r = block.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY < r.bottom) return null;
}
// Cursor is in the rail AND between blocks — snap to the nearest
// seam. No X-bound check needed; we already know X is in the rail.
const seams = listBlockBoundaries(content);
let best: Seam | null = null;
let bestDelta = SEAM_RANGE_PX;
for (const s of seams) {
const delta = Math.abs(s.y - e.clientY);
if (delta < bestDelta) {
best = s;
bestDelta = delta;
}
for (let i = 0; i < blocks.length; i++) {
const r = blocks[i].getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right) continue;
if (e.clientY < r.top || e.clientY > r.bottom) continue;
// Bottom zone: last 25% of block height, capped at 24px so
// short blocks don't get a disproportionate trigger area.
const triggerZone = Math.min(r.height * 0.25, 24);
if (e.clientY < r.bottom - triggerZone) continue;
return {
insertIndex: i + 1,
y: r.bottom,
left: r.left,
right: r.right
};
}
return best;
return null;
}
$effect(() => {
@ -252,26 +201,15 @@
seam = null;
}
/**
* Mirror the drawer's side so the "+" button auto-flips to the
* OPPOSITE gutter (CSS reads `data-side` to swap left:-28 vs
* right:-28). Drawer right (default) → "+" on left margin;
* drawer left → "+" on right margin.
*/
const drawerSide = $derived.by(() => {
// Read reactively each time seam changes — re-evaluating the
// DOM is cheap and the side rarely flips during a session.
if (!seam) return 'right' as const;
const drawer = document.querySelector('[data-words-drawer]');
return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right';
});
// drawerSide flip removed — the "+" lives INSIDE the block now,
// centered on its width, so it doesn't depend on which side the
// drawer sits on.
</script>
{#if seam}
<div
data-words-block-inserter
data-mode={isDragging ? 'drop' : 'insert'}
data-side={drawerSide}
style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;"
role="presentation"
>

@ -10,13 +10,10 @@
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;
/* Flat light silver band with a slightly darker edge toward the
* editor card. Tokens declared in `lib/recipes/base.ts >
* words.rail-bg / rail-border`. Fixed tone — see token comment. */
--_words-rail-bg: var(--words-rail-bg);
--_words-rail-border: var(--words-rail-border);
/* Gutter rail removed — handle and inserter now overlay the block
* itself (handle at lower-left corner, "+" at center-bottom when
* the cursor is near the block's bottom edge). No separate rail
* column, no extra grid space, no dot pattern. */
--_words-toolbar-px: var(--words-toolbar-px-md);
--_words-toolbar-py: var(--words-toolbar-py-md);
--_words-button-size: var(--words-button-size-md);
@ -141,65 +138,31 @@
}
/* ── 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 `<Words.Drawer side="left">` the columns flip. Children without
a grid-area placement (BubbleMenu / SlashMenu / HiddenInput /
find-replace) are absolute-positioned and don't participate. */
Two-column grid: content | drawer (or drawer | content when
side='left'). No more rail column — the gutter overlays (block-
handle, block-inserter) now position themselves DIRECTLY on top of
the block at viewport coordinates (lower-left corner for the grip,
center-bottom for the "+"). 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:
var(--_words-rail-px)
var(--_words-rail-gap)
minmax(0, 1fr)
max-content;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
'rail . toolbar drawer'
'rail . content drawer'
'rail . status drawer';
'toolbar drawer'
'content drawer'
'status drawer';
column-gap: 0;
}
/* 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). */
/* Left-side drawer — drawer first, then content. */
[data-words]:has([data-words-drawer][data-side='left']) {
grid-template-columns:
max-content
minmax(0, 1fr)
var(--_words-rail-gap)
var(--_words-rail-px);
grid-template-columns: max-content minmax(0, 1fr);
grid-template-areas:
'drawer toolbar . rail'
'drawer content . rail'
'drawer status . rail';
}
/* Rail visual — pseudo-element on the editor host that fills the
`rail` grid area with a flat silver band. The edge closest to the
editor card carries a slightly darker hairline so the rail reads as
a separate gutter. 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);
border-inline-end: var(--words-border-width) solid var(--_words-rail-border);
border-radius: var(--_words-radius) 0 0 var(--_words-radius);
pointer-events: none;
}
/* Left-side drawer flips the layout (drawer | content | gap | rail),
so the rail's "darker edge" is on the inline-START (the side facing
the editor card on its right). */
[data-words]:has([data-words-drawer][data-side='left'])::before {
border-inline-end: 0;
border-inline-start: var(--words-border-width) solid var(--_words-rail-border);
border-radius: 0 var(--_words-radius) var(--_words-radius) 0;
'drawer toolbar'
'drawer content'
'drawer status';
}
[data-words]:has([data-words-drawer]) > [data-words-toolbar] {
@ -811,8 +774,11 @@
/* Distinct color against the dotted rail. Accent on hover. */
color: var(--words-command-color);
cursor: grab;
opacity: 0.75;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
/* The handle is shown via JS on block hover (position set when
* cursor is over a top-level block). Full opacity by default —
* the visibility is binary, not faded. */
opacity: 1;
transition: background-color 120ms ease, color 120ms ease;
z-index: var(--z-index-popover);
}
@ -907,14 +873,13 @@
[data-words-block-inserter-button] {
pointer-events: auto;
position: absolute;
/* 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
);
/* Centered horizontally on the block (the wrapper spans the block
width; the button uses left:50% + translateX(-50%) to land in
the middle). Vertically sits at the wrapper's center
(translateY(-50%) on the wrapper places it at the block's
bottom edge). */
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
@ -933,44 +898,27 @@
border-color 120ms ease;
}
/* No :hover state on the inserter button. The block-handle is also
* passive; the gutter overlays should read as ambient affordances,
* not interactive buttons that "light up". (Removed earlier accent
* solid hover rule per spec.) */
/* 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: calc(
-1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px
);
}
/* No :hover state on the inserter button — gutter overlays read as
* ambient affordances, not buttons that "light up". */
[data-words-block-inserter-line] {
pointer-events: auto;
flex: 1 1 auto;
block-size: 2px;
border-radius: 1px;
background: color-mix(in srgb, var(--_words-accent-solid) 35%, transparent);
opacity: 0;
transition: opacity 120ms ease;
}
[data-words-block-inserter]:hover [data-words-block-inserter-line] {
opacity: 1;
/* Solid accent — no transparency, no blur. Per spec: "la line
* debe de ser mas solida sin blur". */
background: var(--_words-accent-solid);
opacity: 0.9;
}
/* Drop-target mode: while a block is being dragged the inserter
doubles as the drop indicator. The line stays prominent (no hover
needed) and grows a touch so the user can see exactly where the
block will land. */
doubles as the drop indicator. The line grows a touch so the user
can see exactly where the block will land. No box-shadow halo —
keep it solid + sharp (per spec). */
[data-words-block-inserter][data-mode='drop'] [data-words-block-inserter-line] {
opacity: 1;
block-size: 3px;
background: var(--_words-accent-solid);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--_words-accent-solid) 25%, transparent);
}
/* Grabbed state on the block-handle grip — visible when the user has

Loading…
Cancel
Save

Powered by TurnKey Linux.