/** * dsh-widgets — the rail magnification wave. * * Moved out of `client/index.ts` (Phase 2.4) unchanged. The component owns its * own interaction geometry (railElement / hitLayout / onCard / moveRailFocus / * leaveRail / nearSurface are all defined below), so the only things it takes * from its callers are the 25 props in `RailWaveProps`. * * Motion model: ONE spring drives every slot — `displayed = 1 + (target - 1) * p` * where `target` is the pointer-live scale array and `p` the spring progress. Both * are continuous, so entering, following and leaving are one uninterrupted curve. */ import * as React from 'react'; import type { WidgetRenderOut, WidgetSize } from '../../lib/contract/types'; import { type WavePlace } from '../geometry'; /** * The inset the rail AUTHORS (`padding: '4px …'` in the rail element below). * * It is a fallback, not the truth: the rail's `padding-top` is not ours alone, and * every geometric test in this file needs the inset the rail actually HAS. Read it * with `readTopInset`, never from this constant (see the note there). */ export declare const RAIL_TOP_INSET = 4; /** * The magnifying rail surface. The magnification wave is the ONLY part of the * rail that changes on a hover frame, so it lives in its own component: a * pointer move re-renders THIS component alone, and the static deck arrives as * an already-built `deck` element whose identity the parent keeps stable —React * bails out of that whole subtree instead of reconciling every card body, every * bridge-derived widget output and the always-mounted add panel 60 times a * second (measured 2026-09-13: 12.5ms p50 / 41.8ms p95 hover frames with the wave * inside the parent, vs 4.2ms idle). * * The enlarged copies are sized through `transform: scale()` with a top-right * origin rather than width/height: `placeCards` gives the focused box as * {top, right, w, h} with the box anchored to its right edge, which is exactly * what scaling the resting-size card around its top-right corner produces. Only * the compositor is involved per frame —no per-frame layout or repaint of seven * dense card subtrees. */ interface RailWaveProps { /** Prebuilt resting deck (identity-stable between pointer frames). */ deck: React.ReactNode; /** * Card bodies for the OVERLAY, built by the parent at the resting unit with the * real interaction handlers (action / cycle / resize). Same length and order as * `items`; identity is stable across hover frames (the parent does not re-render * on pointer moves), which is what keeps a follow frame down to slot styles. */ cardBodies: ReadonlyArray; /** The rail element, used as the coordinate reference for overlay pointer events. */ railElRef: { current: HTMLDivElement | null; }; /** Toggle the add panel (the overlay mirrors the deck's add button). */ onAddClick: () => void; items: ReadonlyArray<{ key: string; size: WidgetSize; baseW: number; out: WidgetRenderOut; w: { id: string; }; }>; side: number; pad: number; railW: number; stackHeight: number; /** * Number of card rows the deck seats (see `rows` in the parent). The scroll * geometry needs it —the detents ARE the rows. */ rows: number; /** * Scroll geometry, owned by the PARENT because it decides the deck's rendered * height (the parent builds the deck element) and the overlay must mirror it * exactly so the two decks stay pixel-identical at rest: * * deckH = max(rows · pitch, stackHeight) + paneH * * where `paneH` is the rail's measured clientHeight — the reservation that * makes the LAST row reachable (see the parent's note). */ deckH: number; /** * The rail's measured viewport height (clientHeight). RailWave needs it to size the * scroll TAIL: the tail turns `deckH` into the rail's scrollable content, and the * content must exceed the deck by exactly one detent-range — enough for the last row * to top out, and not a pixel more (see the tail element's note). */ paneH: number; /** * The rail's live top content inset (`padding-top`, px): 4 authored, more when a co-tenant * seats its band above the deck (see `readTopInset`). ALL whole-card bands and the scroll * cap are built on it, and rail-view re-measures it whenever the rail's style attribute * changes — a co-tenant tween writes the push there, and nothing else would notice, because * `padding-top` moves the content without changing the rail's clientHeight. */ topInset: number; /** * Highest row that may top out (see the parent's scroll-geometry note): the last row * whose CARDS still reach into the viewport. The wheel stops there instead of * scrolling the deck past its own content into blank space. */ lastRow: number; addRadius: number; /** true = the peak follows the pointer every frame; false = quantized wave. */ active: boolean; placeCards: (sc: number[]) => WavePlace[]; scaleFor: (x: number, y: number) => number[]; nearest: (v: number, pts: number[]) => number; stepScale: (d: number) => number; xPts: number[]; yPts: number[]; addSlotFor: (layout: WavePlace[]) => { top: number; right: number; }; /** * Resting deck layout (content coordinates), the same array the static deck * renders from. The wave hit-tests the pointer against THIS when no overlay is * painted, and against its own live `placeCards` layout once one is —testing * the resting geometry while the magnified deck is the painted and interactive * surface is what let the wave stay armed after the pointer had left a * magnified card sideways (see `onCard` in RailWave). */ restLayout: ReadonlyArray; /** Resting add-button slot (content coordinates). */ restAdd: { top: number; right: number; }; /** The rail is mounted on a live session (state is dropped when it is not). */ live: boolean; /** * ── IS ANY OF THE RAIL ACTUALLY ON SCREEN? ── * * False while the drawer is RETIRED (closed but kept mounted — see rail-view.tsx), has no * room to draw, or is COVERED by the official right panel. It MUST gate the hover path, * because this file's "is the pointer on a tile" oracle is ARITHMETIC, not hit-testing: it * reads the tile boxes, and a retired drawer still has them (`visibility: hidden` keeps * layout). Without this gate, moving the pointer over the collapsed rail's band resolves a * tile nobody can see and lights the rail up over an empty patch of the conversation — the * owner's report on 2026-10-03: 「收起组件区域后,鼠标指针悬浮到右侧区域会异常激活组件区域悬浮状态」. * * A prop, not a DOM read: the window-level `mousemove` listener below is mounted ONCE and * reads refs, and the drawer's own attributes belong to the parent's state. */ onScreen: boolean; /** * Is the official right panel sitting ON TOP of the rail? (rail/geometry.ts `RailSpace.swallowed`.) * * Kept SEPARATE from `onScreen` because the two are different facts. `onScreen` is about the * drawer itself (closed / no room); `covered` means the drawer is open and laid out, but the * shell's right column owns the space — geometry.ts resolves exactly that: the rail is pinned * `right: 0px` and shifted by `shiftX` so the panel covers it COMPLETELY. The rail is then not * the surface under the pointer anywhere, so no pointer position may arm or keep armed the * wave. This is what the arithmetic oracle cannot see on its own: the tile boxes are all still * there, under the panel — the owner's report on 2026-10-03: 「右侧打开右侧边栏时也会激活悬浮显示」. * * The magnify layer is PORTALED to (so the drawer's own `[data-yielded]` pointer-events * rule cannot reach it) and carries z-index 26, which out-stacks the panel's un-z-indexed * column: once armed, its cards paint OVER the sidebar. Gating on this prop is therefore what * stops the overlay from being drawn on top of the panel in the first place. */ covered: boolean; /** Rightward shift (px) so a narrower panel still covers the rail completely. */ shiftX: number; /** * Resolved column count of the static deck. The wave only uses it to restart * the "pop" spring when the deck changes shape (2 ⇄3 ⇄4 columns) while the * user drags the conversation width. */ columns: number; /** * Draw WHOLE cards only (prefs.wholeCards): a card the rail's viewport would * cut in half — at the bottom of the visible band, since the row-snap scroll * already guarantees the TOP row tops out exactly — is not painted and not * hit-testable. See `partialAt` for why this is a per-card hide rather than a * clip of the band. */ wholeCards: boolean; /** * The drawer's own phase, forwarded from the parent, or `null` when the deck * must not cascade (`openShape: 'zoom'`). It drives the OPEN/CLOSE cascade: the * deck's cards fold in one after another — from the bottom-left card to the * top-right one — on top of the whole-group slide+zoom. */ deckPhase: 'in' | 'out' | null; } export declare function RailWave(props: RailWaveProps): React.ReactElement; export {};