Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bc6475d
Add deterministic helper overlay placement geometry
nedtwigg Sep 23, 2026
142d680
Anchor helper context beside its source with manual placement controls
nedtwigg Sep 23, 2026
8e40ea9
Simplify helper context placement overlay
nedtwigg Sep 23, 2026
5d16671
Verify helper geometry updates preserve terminal identity and focus
nedtwigg Sep 23, 2026
0731f9b
Expose context details in visual stories and cover small panels
nedtwigg Sep 23, 2026
b25c313
Exercise helper placement in Storybook and fix narrow-panel clipping
nedtwigg Sep 23, 2026
c0eeef7
Simplify helper placement story checks and align notification indent
nedtwigg Sep 23, 2026
6be585d
Merge remote-tracking branch 'origin/main' into helper-placement
nedtwigg Sep 23, 2026
86042d5
Address spec review and repair visual story setup
nedtwigg Sep 23, 2026
2a91088
Inset helpers that overlap their source pane
nedtwigg Sep 23, 2026
d617fc5
Merge remote-tracking branch 'origin/main' into helper-placement
nedtwigg Sep 23, 2026
5bd222d
Overlap adjacent helpers with their source pane
nedtwigg Sep 23, 2026
33ced2c
Group helper header actions and outline the source-helper union
nedtwigg Sep 23, 2026
f11b7a4
Simplify the source-helper union ring
nedtwigg Sep 23, 2026
13555d8
Merge remote-tracking branch 'origin/main' into helper-placement
nedtwigg Sep 23, 2026
a26f6f4
Animate helper focus-ring changes and remove native ghost outline
nedtwigg Sep 23, 2026
d5ca083
Use supplied panel icon for helper placement
nedtwigg Sep 23, 2026
bac2690
Use supplied side-panel icon for left and right placement
nedtwigg Sep 23, 2026
4e59c93
Remove Auto button from helper placement controls
nedtwigg Sep 23, 2026
2303e92
Give helper popups the zoomed pane contrast halo
nedtwigg Sep 23, 2026
3ddd0f5
Lift above helpers clear of their source title
nedtwigg Sep 23, 2026
dea969c
Expand above helpers over peers with a slight source overlap
nedtwigg Sep 23, 2026
bf600fa
Always show terminal context details
nedtwigg Sep 23, 2026
3a3cbfd
Simplify helper placement and track same-side helper motion 1:1
nedtwigg Sep 23, 2026
53a8eed
Return the focus ring as helper dismissal begins
nedtwigg Sep 23, 2026
783a971
Place the terminal context inside the Lath paint before notifying chrome
nedtwigg Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,21 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal-

**Must open the terminal context from terminal header, body, and command-mode `a` and `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input.

**Must float the context inside its source Pane with a one-rem inset on every side**, overlapping the header, with a theme-derived edge and raised shadow. Render it in the Lath leaf's overlay slot, outside the body's clipping box, so it follows the leaf's layout without remounting the helper. Keep one context per Wall. Outside pointer press and explicit close dismiss it. No separate context heading or clipboard toolbar is shown.
**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it.

**Must choose placement on opening and retain its side while usable.** Never reposition in response to terminal output. Minimized panes do not count; zoom uses single-pane placement.

| Layout | Placement |
|---|---|
| Multiple visible panes | Beside the source with 16px overlap; match its size where possible. Above helpers overlap 4px and extend 32px farther upward over peer headers. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. |
| No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. |
| Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. |

Popups share the zoomed pane’s app-background halo.

**Must group available side buttons beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk.

**Must always show source title, directory actions, ports, alerts, and helper actions**, with title explanation available through Explain. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content.

**Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay.

Expand All @@ -86,7 +100,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal-

**Must promote by adopting the helper Session into a new split beside the source**, preserving identity and focusing it. Helper lifetime and source closure are owned by `docs/specs/terminal-context.md`.

Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `TerminalLeafOverlay` in `lib/src/components/wall/LathHost.tsx`; `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `.terminal-context-enter` / `.terminal-context-content` in `lib/src/theme.css`. Tests: `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`.
Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `.terminal-context-enter` / `.terminal-context-content` in `lib/src/theme.css`. Tests: `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/wall/TerminalContextOverlay.test.tsx`, `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/Wall.test.tsx`.

### Pane body

Expand Down Expand Up @@ -326,6 +340,8 @@ Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fas

## Selection overlay

**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators.

A fixed-positioned element on top of the Lath host, covering the active element's area inflated by `SELECTION_RING_INFLATE_PX` (4px) for panes; doors are not inflated. **The inflate is derived in `lib/src/components/design.tsx` so both ring strokes center on the gutter's midline** (rationale).

- **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`).
Expand All @@ -337,13 +353,16 @@ A fixed-positioned element on top of the Lath host, covering the active element'
- `z-index: SELECTION_RING_Z_INDEX` (50), `pointer-events: none`. Under `WorkspaceWindow` it renders into `document.body`, outside the Workspace's transform and stacking context.
- **Every modal must render into `document.body` too, at a `MODAL_LAYERS` value above the ring's** (`ModalOverlay`), or the ring crosses it — by value, never insertion order. Pinned by `lib/src/components/ModalOverlay.test.tsx`.

Source of truth: `rectUnionOutline` in `lib/src/lib/rect-union-outline.ts` and `WorkspaceSelectionOverlay` in `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`.

### Ring travel

The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a JS tween, never a CSS transition**; DESIGN.md's ban on animating layout properties does not reach it (rationale). Motion is `FOCUS_MOTION_MS` (220ms — half `LATH_MOTION_MS`) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`.

Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to its stable shell; the rAF loop writes rect, path `d`, marching dash, and smear geometry, then **re-applies after structural renders, pre-paint**, so fresh nodes do not flash. **Never reintroduce per-frame React state** — reconciling this subtree competes with travel for the frame budget (rationale).

- **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive.
- **Helper side is identity.** An open helper appends its side, so opening, closing and switching sides tween the union’s two rectangles from the painted frame, including interrupted motion; same-side motion follows the same-identity rules below.
- **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it.
- **In-flight retarget.** A same-identity re-measure *during* a tween retargets the destination **without resetting the clock**, so the ring converges on a moving target (select-a-neighbor-during-kill) and still lands on the original completion instant.
- **Snap gate.** `motionIsInstant()` — `!cfg.layout.animate` (visual snapshots) or `prefersReducedMotion()` — settles the ring instantly; it is the same predicate the Lath animator's duration uses, so ring and leaves agree. **A ring appearing with nothing on screen also snaps**: there is no `from` to glide from.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/terminal-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Source of truth: `context` in `standalone/sidecar/pty-core.js`; `terminalContext

**Must share the context presentation between the live menu and its state gallery.**

Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell.
Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. `lib/src/stories/HelperPlacement.stories.tsx` checks rendered placement and real xterm input/focus retention; the context gallery checks narrow controls and always-visible details.

## Tool context

Expand Down
1 change: 1 addition & 0 deletions docs/specs/tiling-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Source of truth: `lib/src/components/wall/lath-wall-store.ts`; `lib/src/componen
- Sashes render from core `sashes()` geometry as sibling divs (hit area widened to 8px, cursor per axis); a drag streams a core `resize` preview from the drag-start tree with the cumulative delta and proposes one commit on pointerup (`onCommitResize`); Escape cancels. **Geometry is reported through `store.setLayoutGeometry` from inside the measuring layout effect, never a passive effect over the rendered size** (rationale); the store's zero-area rejection is the backstop.
- Zoom retargets only the chosen leaf to the wall rect inset by `LATH_ZOOM_MARGIN` (half a pane header) and elevates it above tiled/dying panes and sashes, applying the blurred `LATH_ZOOM_SHADOW` while elevated. Unzoom keeps both until the return frame settles.
- **The binding never calls `.focus()` and emits no activation events.** Gestures surface as proposals (`onCommitResize`, `onLeafFocused`, the drag callbacks) that the Wall commits.
- Terminal Context renders above the tiled leaves. **Its placer runs inside each paint, before `notifyFrames`**, so the ring measures the helper where it is painted; `docs/specs/layout.md` → Header context menu owns the context.
- The selection ring and kill overlay measure leaf elements through `resolvePaneElement`, which climbs to `[data-lath-leaf]`; `WorkspaceSelectionOverlay` re-measures on every store commit (`revision`) and every animator tick, and **same-identity re-measures snap 1:1**, so the ring tracks kills, restores, and tweens frame-accurately ([layout.md → Ring travel](layout.md#ring-travel) owns its between-panes travel, a JS tween rather than a CSS transition).

Source of truth: `BODY_COMPONENTS` / `TAB_COMPONENTS` / `OVERLAY_COMPONENTS` in `lib/src/components/wall/LathHost.tsx`; the `.lath-host` rules in `lib/src/index.css`.
Expand Down
33 changes: 32 additions & 1 deletion lib/src/components/Wall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3127,7 +3127,8 @@ describe('Wall on the Lath engine', () => {
);
expect(portRow).not.toBeNull();
const contextMenu = portRow!.closest('[data-terminal-context]')!;
expect(contextMenu.closest('[data-lath-leaf]')).toBe(header.closest('[data-lath-leaf]'));
expect(contextMenu.closest('[data-lath-leaf]')).toBeNull();
expect(contextMenu.closest('.lath-host')).toBe(header.closest('.lath-host'));
expect(contextMenu.closest('.lath-leaf-body')).toBeNull();
await act(async () => {
portRow!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
Expand Down Expand Up @@ -3798,3 +3799,33 @@ it('leaves a reveal for a hidden Workspace unanswered', async () => {
expect(refit).not.toHaveBeenCalled();
expect(container.querySelector('[data-terminal-context]')).toBeNull();
});

it('moves a retained helper without resizing or replacing its source, and remembers the manual side', async () => {
const retained: helpers.HelperTerminal = { id: 'placement-helper', parentId: 'placement-source', command: '', status: 'preserved' };
vi.spyOn(helpers, 'getHelper').mockImplementation(id => id === 'placement-source' ? retained : undefined);
const openHelper = vi.spyOn(helpers, 'openHelper').mockResolvedValue(retained);
await act(async () => root.render(<Wall initialPaneIds={['placement-source']} />));
await flush();
const source = container.querySelector<HTMLElement>('[data-lath-leaf="placement-source"]')!;
const sourceStyle = source.getAttribute('style');
const open = async () => {
act(() => container.querySelector('[data-pane-header-for="placement-source"]')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })));
await flush();
};
await open();
const menu = container.querySelector<HTMLElement>('[data-terminal-context]')!;
const terminal = menu.querySelector('[data-helper-terminal]');
expect(terminal).not.toBeNull();
act(() => menu.querySelector<HTMLButtonElement>('[aria-label="Place helper at bottom"]')!.click());
expect(menu.dataset.contextSide).toBe('bottom');
expect(menu.querySelector('[data-helper-terminal]')).toBe(terminal);
expect(openHelper).toHaveBeenCalledTimes(1);
expect(source.getAttribute('style')).toBe(sourceStyle);
expect(container.querySelector('[data-lath-leaf="placement-source"]')).toBe(source);
act(() => menu.querySelector<HTMLButtonElement>('[aria-label="Close terminal context"]')!.click());
await flush();
await open();
expect(container.querySelector<HTMLElement>('[data-terminal-context]')!.dataset.contextSide).toBe('bottom');
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Place helper at top"]')!.click());
expect(container.querySelector<HTMLElement>('[data-terminal-context]')!.dataset.contextSide).toBe('top');
});
2 changes: 1 addition & 1 deletion lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2325,7 +2325,7 @@ export function Wall({
externalDrag={doorDrag ? { id: doorDrag.item.id, startX: doorDrag.startX, startY: doorDrag.startY } : null}
onExternalDrop={onExternalDrop}
/>
<WorkspaceSelectionOverlay lathStore={lath.store} subscribeLathFrames={lath.subscribeFrames} selectedId={selectedId} selectedType={selectedType} mode={mode} active={active} />
<WorkspaceSelectionOverlay lathStore={lath.store} subscribeLathFrames={lath.subscribeFrames} selectedId={terminalContext?.id ?? selectedId} selectedType={terminalContext ? 'pane' : selectedType} mode={mode} active={active} contextHelper={lath.contextHelper} />
</div>
</div>

Expand Down
3 changes: 3 additions & 0 deletions lib/src/components/design.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { OVERLAY_VIEWPORT_MARGIN_PX } from '../lib/ui-geometry';
* elevated zoom inset) must use this constant so the chrome stays proportional. */
export const PANE_HEADER_HEIGHT_PX = 30;

/** Soft app-ground halo separates zoomed panes and context popups from content below. */
export const ELEVATED_PANE_SHADOW = '0 0 5px 5px var(--color-app-bg)';

// Pane headers/doors own the top corners; terminal bodies own the bottom.
// All terminal-radius constants derive from this single source so the CSS
// class, the SVG-friendly px value, and the inline-style rem string can't
Expand Down
24 changes: 23 additions & 1 deletion lib/src/components/wall/LathHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LathHost, LATH_ZOOM_MARGIN, LATH_ZOOM_SHADOW } from './LathHost';
import { createLathWallStore, type LathWallStore, type LeafMeta, LATH_LAYOUT_OPTS } from './lath-wall-store';
import { createLathWallEngine } from './lath-wall-engine';
import { type ContextHelper, createLathWallEngine } from './lath-wall-engine';
import { layout } from '../../lib/lath/layout';
import { LATH_EASING } from '../../lib/lath/animator';
import { type DropTarget, move } from '../../lib/lath/ops';
Expand Down Expand Up @@ -438,6 +438,28 @@ describe('LathHost — empty tree', () => {
});
});

describe('LathHost — terminal context placement', () => {
it('places the context from each painted frame before notifying chrome', () => {
const store = seeded(rowOf('a', 'b'), [['a', leafMeta({ title: 'A' })], ['b', leafMeta({ title: 'B' })]]);
const { engine } = mount(store);
const element = document.createElement('div');
const placedWidths: number[] = [];
const seen: (ContextHelper | null)[] = [];
const unsubscribe = engine.subscribeFrames(() => seen.push(engine.contextHelper()));
act(() => engine.setContextPlacer(paint => {
placedWidths.push(paint.get('a')!.rect.width);
return { sourceId: 'a', element, side: 'right' };
}));
expect(seen.at(-1)).toEqual({ sourceId: 'a', element, side: 'right' });
expect(`${placedWidths.at(-1)}px`).toBe(leafDiv('a')!.style.width);
act(() => store.addLeaf('c', leafMeta({ title: 'C' }), { refId: 'b', edge: 'right' }));
expect(`${placedWidths.at(-1)}px`).toBe(leafDiv('a')!.style.width);
act(() => engine.setContextPlacer(null));
expect(seen.at(-1)).toBeNull();
unsubscribe();
});
});

describe('LathHost — imperative animation frames', () => {
const DUR = 400;
let clock: number;
Expand Down
Loading
Loading