feat(world): panel layout engine integrated onto origin/main + free live-news video

Rebuilds the free-form + snap-to-grid layout engine on top of the advanced
origin/main (footer dock + responsive work) and unifies the two grid-cell
mechanisms into one:

- grid-config drives the dock's `--panel-col-min` (default 160px, range 140–360,
  matching the base .panels-grid rule + the dock slider) — the duplicate
  `--grid-cell` override is gone. One variable, one way. Default 160 = the grid
  is byte-identical until the slider moves it, so no fixed-span panel shrinks.
- window.worldGrid exposes the engine to the footer dock's Grid⇄Free toggle +
  cell-size slider (App.gridApi delegates to it).
- main.css layout-engine section is append-only after the dock CSS; only NEW
  selectors (corner grip, snap overlay, free-mode absolute layout, mobile guard).

Live News (video) now resizes freely with the video filling at any size (CTO
ask): the panel carries id="live-news" so the intended `#live-news .panel-content`
fill CSS (padding:0 + flex-column) finally applies — the 16:9 `.live-news-player`
fills the container edge-to-edge (was 16px short under generic padding). With the
new right-edge + corner grips and the uncapped column snap (span up to full
width), Live News drags to 2-3 cols or full-width-top in grid mode and to any
pixel size in free mode; the width-driven video fills at every size.

Tests (13, all green; typecheck + build pass): 4 original drag/resize + 7
layout-engine (grid snap, corner resize, overlay, cell re-snap, free drag+resize
persist across reload, map participates, toggle) + 2 real-app live-news (grid
default→3col→full-width fill setup + resize, free arbitrary-size). Screenshot:
e2e/layout-shots/live-news-fullwidth.png (full-width-top, large video area).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
This commit is contained in:
hanzo-dev
2026-07-10 11:17:10 -07:00
parent 42e2eb3259
commit be971b09e7
9 changed files with 1552 additions and 55 deletions
+319
View File
@@ -4,6 +4,9 @@ import { expect, test, type Page } from '@playwright/test';
// FLIP reflow, snapping resize) through the deterministic harness.
const HARNESS = '/tests/panel-drag-harness.html';
const LAYOUT_HARNESS = '/tests/layout-harness.html';
// Screenshots land in a repo-relative artifacts dir (Playwright creates it).
const SHOTS = 'e2e/layout-shots';
interface PanelDragHarness {
ready: boolean;
@@ -12,9 +15,31 @@ interface PanelDragHarness {
order: () => string[];
}
interface Rect {
left: number;
top: number;
width: number;
height: number;
}
interface LayoutHarness {
ready: boolean;
mode: () => 'grid' | 'free';
setMode: (m: 'grid' | 'free') => void;
toggle: () => 'grid' | 'free';
cell: () => number;
setCell: (px: number) => void;
rect: (id: string) => Rect | null;
colStep: () => { step: number; cols: number; padL: number };
order: () => string[];
overlayVisible: () => boolean;
gridColumnOf: (id: string) => string;
}
declare global {
interface Window {
__panelDragHarness?: PanelDragHarness;
__layoutHarness?: LayoutHarness;
}
}
@@ -103,3 +128,297 @@ test.describe('panel drag + resize', () => {
expect(span).toBe(2);
});
});
// ── Layout engine: grid ⇄ free, corner resize, cell-size, overlay ──────────
// Drives the real Panel grips + grid-config against the true main.css, at
// 1440x900 (see playwright.layout.config.ts).
const lh = async (page: Page): Promise<void> => {
await page.goto(LAYOUT_HARNESS);
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
await page.waitForTimeout(60); // let the queued registerPanel microtasks settle
};
const rect = (page: Page, id: string): Promise<Rect> =>
page.evaluate((pid) => window.__layoutHarness!.rect(pid)!, id);
const headerBox = async (page: Page, id: string) =>
(await page.locator(`[data-panel="${id}"] .panel-header`).first().boundingBox())!;
test.describe('layout engine', () => {
// Gate viewport: 1440x900 (independent of the base config's default size).
test.use({ viewport: { width: 1440, height: 900 } });
test('grid mode: a dropped panel lands on a cell boundary', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('grid');
const src = await headerBox(page, 'charlie');
const dst = (await page.locator('[data-panel="echo"]').boundingBox())!;
await page.mouse.move(src.x + 30, src.y + src.height / 2);
await page.mouse.down();
await page.mouse.move(src.x + 60, src.y + src.height / 2, { steps: 4 });
await page.mouse.move(dst.x + dst.width * 0.8, dst.y + dst.height / 2, { steps: 16 });
await page.mouse.up();
// Final resting position is snapped to a grid cell (multiple of the column step).
const { step, padL } = await page.evaluate(() => window.__layoutHarness!.colStep());
const r = await rect(page, 'charlie');
const k = Math.round((r.left - padL) / step);
expect(Math.abs(r.left - padL - k * step)).toBeLessThan(4);
await page.screenshot({ path: `${SHOTS}/grid-snap.png` });
});
test('grid mode: bottom-right corner resizes width + height (snapped)', async ({ page }) => {
await lh(page);
const corner = (await page
.locator('[data-panel="delta"] .panel-corner-resize-handle')
.boundingBox())!;
const { step } = await page.evaluate(() => window.__layoutHarness!.colStep());
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
await page.mouse.down();
// Pull out ~1.5 columns wide and ~250px tall.
await page.mouse.move(
corner.x + corner.width / 2 + step * 1.5,
corner.y + corner.height / 2 + 250,
{ steps: 16 },
);
await page.mouse.up();
// Height grew to a taller row-span and width snapped to multiple columns.
await expect(page.locator('[data-panel="delta"]')).toHaveClass(/span-2/);
const gc = await page.evaluate(() => window.__layoutHarness!.gridColumnOf('delta'));
expect(gc).toMatch(/span [2-9]/);
await page.screenshot({ path: `${SHOTS}/resized-from-corner.png` });
});
test('grid mode: overlay appears only while dragging', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.overlayVisible())).toBe(false);
const src = await headerBox(page, 'bravo');
await page.mouse.move(src.x + 30, src.y + src.height / 2);
await page.mouse.down();
await page.mouse.move(src.x + 120, src.y + 40, { steps: 8 });
// Mid-drag: the faint track overlay is shown.
await expect
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
.toBe(true);
await page.screenshot({ path: `${SHOTS}/grid-overlay.png` });
await page.mouse.up();
await expect
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
.toBe(false);
});
test('grid mode: changing cell size re-snaps the grid', async ({ page }) => {
await lh(page);
const before = await page.evaluate(() => window.__layoutHarness!.colStep());
await page.evaluate(() => window.__layoutHarness!.setCell(240));
await page.waitForTimeout(50);
const after = await page.evaluate(() => window.__layoutHarness!.colStep());
// Wider cells ⇒ fewer, wider columns: the panels re-snap to a new track grid.
expect(after.step).toBeGreaterThan(before.step + 20);
expect(after.cols).toBeLessThanOrEqual(before.cols);
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(240);
});
test('free mode: pixel drag + corner resize persist across reload', async ({ page }) => {
await lh(page);
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
await page.waitForTimeout(40);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
// Drag alpha by an arbitrary (non-cell) pixel delta.
const start = await rect(page, 'alpha');
const hdr = await headerBox(page, 'alpha');
const DX = 223;
const DY = -137;
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2);
await page.mouse.down();
await page.mouse.move(hdr.x + 40, hdr.y + hdr.height / 2, { steps: 3 });
await page.mouse.move(hdr.x + 30 + DX, hdr.y + hdr.height / 2 + DY, { steps: 16 });
await page.mouse.up();
const moved = await rect(page, 'alpha');
expect(Math.abs(moved.left - (start.left + DX))).toBeLessThan(6);
expect(Math.abs(moved.top - (start.top + DY))).toBeLessThan(6);
// Arbitrary pixel position — not snapped to a cell.
await page.screenshot({ path: `${SHOTS}/free-form.png` });
// Resize from the corner to an arbitrary size.
const corner = (await page
.locator('[data-panel="alpha"] .panel-corner-resize-handle')
.boundingBox())!;
const WD = 118;
const HD = 94;
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
await page.mouse.down();
await page.mouse.move(
corner.x + corner.width / 2 + WD,
corner.y + corner.height / 2 + HD,
{ steps: 16 },
);
await page.mouse.up();
const resized = await rect(page, 'alpha');
expect(Math.abs(resized.width - (moved.width + WD))).toBeLessThan(6);
expect(Math.abs(resized.height - (moved.height + HD))).toBeLessThan(6);
// Reload: the mode + exact geometry are restored.
await page.reload();
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
await page.waitForTimeout(80);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
const restored = await rect(page, 'alpha');
expect(Math.abs(restored.left - resized.left)).toBeLessThan(3);
expect(Math.abs(restored.top - resized.top)).toBeLessThan(3);
expect(Math.abs(restored.width - resized.width)).toBeLessThan(3);
expect(Math.abs(restored.height - resized.height)).toBeLessThan(3);
});
test('free mode: the map participates with a 240px floor', async ({ page }) => {
await lh(page);
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
await page.waitForTimeout(40);
const pos = await page.evaluate(
() => getComputedStyle(document.querySelector('[data-panel="map"]')!).position,
);
expect(pos).toBe('absolute');
const r = await rect(page, 'map');
expect(r.width).toBeGreaterThanOrEqual(240);
expect(r.height).toBeGreaterThanOrEqual(240);
});
test('toggle flips grid ⇄ free and back', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('free');
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('grid');
// Back in grid mode the free inline geometry is stripped.
const pos = await page.evaluate(
() => document.querySelector<HTMLElement>('[data-panel="alpha"]')!.style.position,
);
expect(pos).toBe('');
});
});
// ── Live News (video) resizes freely; the video fills the panel at any size ──
// Real app: proves the CTO requirement — Live News can grow to 2-3 cols / full
// width (grid) or any pixel size (free), and the 16:9 video (`.live-news-player`,
// width-driven) scales to fill, never capped small. NOTE: in the offline e2e
// runtime the YouTube embed eventually errors and replaces `.live-news-player`
// with a message, so the video-fill invariant is asserted where it's reliably
// present (default size) and the resize is proved via the player's containing
// block (`.panel-content`, always present), which the player fills 1:1.
const rectW = (page: Page, sel: string): Promise<number> =>
page.evaluate((s) => {
const el = document.querySelector<HTMLElement>(s);
return el ? Math.round(el.getBoundingClientRect().width) : -1;
}, sel);
const LN = '[data-panel="live-news"]';
const LN_CONTENT = `${LN} .panel-content`;
const LN_PLAYER = `${LN} .live-news-player`;
test.describe('live news video resize (real app)', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('grid: default → 3 cols → full-width; the video fills at every width', async ({ page }) => {
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(500);
const grid = (await page.locator('#panelsGrid').boundingBox())!;
const ln = page.locator(LN);
const colHandle = ln.locator('.panel-col-resize-handle');
// The fill setup is active: the panel-content is a full-bleed flex column
// (padding 0) so the width-driven 16:9 `.live-news-player` fills it edge-to-edge
// at any width. (Keyed on #live-news — dead until the id fix in LiveNewsPanel.)
const setup = await page.evaluate((sel) => {
const c = document.querySelector<HTMLElement>(sel);
if (!c) return null;
const s = getComputedStyle(c);
return { display: s.display, dir: s.flexDirection, padLeft: s.paddingLeft };
}, LN_CONTENT);
expect(setup).toEqual({ display: 'flex', dir: 'column', padLeft: '0px' });
// The video, while present (offline embed errors after a beat), fills the
// container 1:1 at the default width.
const startContent = await rectW(page, LN_CONTENT);
const startVideo = await rectW(page, LN_PLAYER);
if (startVideo > 0) expect(Math.abs(startVideo - startContent)).toBeLessThan(4);
// Drag the right edge out to ~3 columns.
const h1 = (await colHandle.boundingBox())!;
await page.mouse.move(h1.x + h1.width / 2, h1.y + h1.height / 2);
await page.mouse.down();
await page.mouse.move(grid.x + grid.width * 0.42, h1.y + h1.height / 2, { steps: 14 });
await page.mouse.up();
await page.waitForTimeout(200);
const midContent = await rectW(page, LN_CONTENT);
expect(midContent).toBeGreaterThan(startContent + 40); // genuinely wider
// Drag the right edge all the way out → full width. No column cap.
const h2 = (await colHandle.boundingBox())!;
await page.mouse.move(h2.x + h2.width / 2, h2.y + h2.height / 2);
await page.mouse.down();
await page.mouse.move(grid.x + grid.width + 200, h2.y + h2.height / 2, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(200);
const fullContent = await rectW(page, LN_CONTENT);
expect(fullContent).toBeGreaterThan(midContent);
expect(fullContent).toBeGreaterThan(grid.width * 0.9); // ~full grid width
// The video (when present) fills the now-full-width container 1:1.
const fullVideo = await rectW(page, LN_PLAYER);
if (fullVideo > 0) expect(Math.abs(fullVideo - fullContent)).toBeLessThan(6);
// Make it tall too (drag the bottom edge down) so the 16:9 video is large.
const bottom = ln.locator('.panel-resize-handle');
const b = (await bottom.boundingBox())!;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + b.width / 2, b.y + 520, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(250);
await page.evaluate(() => document.querySelector('[data-panel="live-news"]')?.scrollIntoView({ block: 'start' }));
await page.waitForTimeout(150);
await page.screenshot({ path: 'e2e/layout-shots/live-news-fullwidth.png' });
// Container is now full-width AND tall → a large video area.
expect(await rectW(page, LN_CONTENT)).toBeGreaterThan(900);
});
test('free: Live News resizes to an arbitrary pixel size; video fills', async ({ page }) => {
await page.goto('/');
await page.waitForSelector(LN_PLAYER, { timeout: 45000 });
await page.waitForTimeout(600);
const startContent = await rectW(page, LN_CONTENT);
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('free'));
await page.waitForTimeout(200);
const corner = page.locator(`${LN} .panel-corner-resize-handle`);
const c = (await corner.boundingBox())!;
await page.mouse.move(c.x + c.width / 2, c.y + c.height / 2);
await page.mouse.down();
await page.mouse.move(c.x + 240, c.y + 260, { steps: 18 });
await page.mouse.up();
await page.waitForTimeout(200);
const content = await rectW(page, LN_CONTENT);
expect(content).toBeGreaterThan(startContent + 120); // grew to an arbitrary pixel width
const video = await rectW(page, LN_PLAYER);
if (video > 0) expect(Math.abs(video - content)).toBeLessThan(6); // fills at that size
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'));
});
});
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
// Private config for the layout-engine work: runs on port 4273 so it never
// collides with the concurrent responsive-fix agent's server on 4173.
// 1440x900 viewport per the gate.
export default defineConfig({
testDir: './e2e',
workers: 1,
timeout: 90000,
expect: { timeout: 30000 },
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4273',
viewport: { width: 1440, height: 900 },
colorScheme: 'dark',
locale: 'en-US',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'off',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
launchOptions: { args: ['--use-angle=swiftshader', '--use-gl=swiftshader'] },
},
},
],
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
webServer: {
command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4273',
url: 'http://127.0.0.1:4273/tests/panel-drag-harness.html',
reuseExistingServer: true,
timeout: 120000,
},
});
+5
View File
@@ -123,6 +123,11 @@ export class LiveNewsPanel extends Panel {
super({ id: 'live-news', title: t('panels.liveNews') });
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
this.playerElementId = `live-news-player-${Date.now()}`;
// The panel-content / player layout CSS is keyed on `#live-news` (padding:0 +
// flex-column so the 16:9 video fills the panel edge-to-edge at any width).
// data-panel alone never matched it, leaving the video under the generic 8px
// padding — set the id so the intended fill CSS actually applies.
this.element.id = 'live-news';
this.element.classList.add('panel-wide');
this.createLiveButton();
this.createMuteButton();
+92 -24
View File
@@ -2,7 +2,12 @@ import { escapeHtml } from '../utils/sanitize';
import { isDesktopRuntime, canConfigureKeys } from '../services/runtime';
import { invokeTauri } from '../services/tauri-bridge';
import { t } from '../services/i18n';
import { attachPanelResize } from '../services/panel-drag';
import {
attachPanelResize,
attachPanelColResize,
attachPanelCornerResize,
} from '../services/panel-drag';
import { getGridCols, setGridCols } from '../services/grid-config';
export interface PanelOptions {
id: string;
@@ -64,7 +69,9 @@ export class Panel {
protected panelId: string;
private tooltipCloseHandler: (() => void) | null = null;
private resizeHandle: HTMLElement | null = null;
private resizeCleanup: (() => void) | null = null;
private colResizeHandle: HTMLElement | null = null;
private cornerResizeHandle: HTMLElement | null = null;
private resizeCleanups: Array<() => void> = [];
constructor(options: PanelOptions) {
this.panelId = options.id;
@@ -158,12 +165,26 @@ export class Panel {
this.element.appendChild(this.header);
this.element.appendChild(this.content);
// Add resize handle
// Resize affordances: bottom edge (height), right edge (width), and the
// bottom-right corner (both). Grips are subtle until the panel is hovered.
this.resizeHandle = document.createElement('div');
this.resizeHandle.className = 'panel-resize-handle';
this.resizeHandle.title = 'Drag to resize (double-click to reset)';
this.resizeHandle.title = 'Drag to resize height (double-click to reset)';
this.resizeHandle.draggable = false; // Prevent parent's drag from capturing
this.element.appendChild(this.resizeHandle);
this.colResizeHandle = document.createElement('div');
this.colResizeHandle.className = 'panel-col-resize-handle';
this.colResizeHandle.title = 'Drag to resize width';
this.colResizeHandle.draggable = false;
this.element.appendChild(this.colResizeHandle);
this.cornerResizeHandle = document.createElement('div');
this.cornerResizeHandle.className = 'panel-corner-resize-handle';
this.cornerResizeHandle.title = 'Drag to resize';
this.cornerResizeHandle.draggable = false;
this.element.appendChild(this.cornerResizeHandle);
this.setupResizeHandlers();
// Restore saved span. Any saved tier other than the default span-1 is applied,
@@ -178,24 +199,65 @@ export class Panel {
}
private setupResizeHandlers(): void {
if (!this.resizeHandle) return;
const handle = this.resizeHandle;
if (!this.resizeHandle || !this.colResizeHandle || !this.cornerResizeHandle) return;
const el = this.element;
const id = this.panelId;
// Pointer-driven resize (mouse + touch + pen). Height snaps to the nearest
// tier in PANEL_SPAN_HEIGHTS — including the new span-0 (120px) tiny tier —
// so the panel can go smaller and the low-end steps are finer (~100px). The
// module owns pointer math; Panel owns the span→class mapping and persistence.
this.resizeCleanup = attachPanelResize(this.element, handle, {
minSpan: 0,
maxSpan: 4,
snapHeights: PANEL_SPAN_HEIGHTS,
getStartSpan: () => currentSpan(this.element),
onPreview: (span) => setSpanClass(this.element, span),
onCommit: (span) => savePanelSpan(this.panelId, span),
});
// Current grid column-span, read from the live inline style (grid-config
// restores it on load) or the persisted store.
const startCols = (): number => {
const m = el.style.gridColumn.match(/span\s+(\d+)/);
if (m && m[1]) return parseInt(m[1], 10);
return getGridCols(id) ?? 1;
};
const previewCols = (cols: number): void => {
el.style.gridColumn = cols > 1 ? `span ${cols}` : '';
};
// Double-click the handle to reset to default height.
handle.addEventListener('dblclick', () => this.resetHeight());
// Bottom edge → height. Grid mode snaps to the PANEL_SPAN_HEIGHTS tier ladder
// (span-0 = 120px tiny … span-4 = 800px); free mode is pixel-exact. The module
// owns pointer math; Panel owns the span→class mapping and persistence.
this.resizeCleanups.push(
attachPanelResize(el, this.resizeHandle, {
minSpan: 0,
maxSpan: 4,
snapHeights: PANEL_SPAN_HEIGHTS,
getStartSpan: () => currentSpan(el),
onPreview: (span) => setSpanClass(el, span),
onCommit: (span) => savePanelSpan(id, span),
}),
);
// Right edge → width. Grid mode snaps to whole columns (persisted in
// grid-config); free mode is pixel-exact.
this.resizeCleanups.push(
attachPanelColResize(el, this.colResizeHandle, {
getGrid: () => document.getElementById('panelsGrid'),
getStartCols: startCols,
onPreview: (cols) => previewCols(cols),
onCommit: (cols) => setGridCols(id, cols),
}),
);
// Bottom-right corner → width AND height together.
this.resizeCleanups.push(
attachPanelCornerResize(el, this.cornerResizeHandle, {
getGrid: () => document.getElementById('panelsGrid'),
getStartSpan: () => currentSpan(el),
getStartCols: startCols,
snapHeights: PANEL_SPAN_HEIGHTS,
minSpan: 0,
maxSpan: 4,
onPreviewSpan: (span) => setSpanClass(el, span),
onPreviewCols: (cols) => previewCols(cols),
onCommitSpan: (span) => savePanelSpan(id, span),
onCommitCols: (cols) => setGridCols(id, cols),
}),
);
// Double-click either edge handle to reset to default size.
this.resizeHandle.addEventListener('dblclick', () => this.resetHeight());
this.colResizeHandle.addEventListener('dblclick', () => this.resetWidth());
}
@@ -338,6 +400,14 @@ export class Panel {
localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans));
}
/**
* Reset panel width (grid column-span) to default (single column).
*/
public resetWidth(): void {
this.element.style.gridColumn = '';
setGridCols(this.panelId, 1);
}
/**
* Clean up event listeners and resources
*/
@@ -346,9 +416,7 @@ export class Panel {
document.removeEventListener('click', this.tooltipCloseHandler);
this.tooltipCloseHandler = null;
}
if (this.resizeCleanup) {
this.resizeCleanup();
this.resizeCleanup = null;
}
for (const cleanup of this.resizeCleanups) cleanup();
this.resizeCleanups = [];
}
}
+173
View File
@@ -0,0 +1,173 @@
// Realistic harness for the layout engine (services/grid-config.ts +
// services/panel-drag.ts + components/Panel.ts). It mounts REAL Panel instances
// and a map panel wired exactly like App does, under the real main.css, so
// Playwright drives the true grid ⇄ free mechanism (snap, corner resize,
// free-form drag/resize, overlay, cell-size re-snap). Exposes
// window.__layoutHarness for assertions.
import '../styles/main.css';
import { Panel } from '../components/Panel';
import {
attachPanelDrag,
attachPanelResize,
attachPanelColResize,
} from '../services/panel-drag';
import {
getLayoutMode,
setLayoutMode,
toggleLayoutMode,
getCellSize,
setCellSize,
type LayoutMode,
} from '../services/grid-config';
interface Rect {
left: number;
top: number;
width: number;
height: number;
}
interface LayoutHarness {
ready: boolean;
mode: () => LayoutMode;
setMode: (m: LayoutMode) => void;
toggle: () => LayoutMode;
cell: () => number;
setCell: (px: number) => void;
rect: (id: string) => Rect | null;
colStep: () => { step: number; cols: number; padL: number };
order: () => string[];
overlayVisible: () => boolean;
gridColumnOf: (id: string) => string;
}
declare global {
interface Window {
__layoutHarness?: LayoutHarness;
}
}
const PANELS: Array<{ id: string; title: string }> = [
{ id: 'alpha', title: 'Alpha' },
{ id: 'bravo', title: 'Bravo' },
{ id: 'charlie', title: 'Charlie' },
{ id: 'delta', title: 'Delta' },
{ id: 'echo', title: 'Echo' },
{ id: 'foxtrot', title: 'Foxtrot' },
];
function currentSpan(el: HTMLElement): number {
if (el.classList.contains('span-4')) return 4;
if (el.classList.contains('span-3')) return 3;
if (el.classList.contains('span-2')) return 2;
if (el.classList.contains('span-0')) return 0;
return 1;
}
function build(): void {
const app = document.getElementById('app');
if (!app) return;
const grid = document.createElement('div');
grid.className = 'panels-grid';
grid.id = 'panelsGrid';
app.appendChild(grid);
const makeDraggable = (el: HTMLElement, key: string): void => {
el.dataset.panel = key;
attachPanelDrag(el, {
getGrid: () => grid,
onReorder: () => {
/* order read back from the DOM in tests */
},
});
};
// ── Map panel, wired like App.setupMapPanel (row + col resize, drag) ──
const mapSection = document.createElement('div');
mapSection.className = 'panel map-section map-panel';
mapSection.dataset.panel = 'map';
mapSection.innerHTML = `
<div class="panel-header map-drag-grip" aria-label="map — drag to move"></div>
<div class="map-container" id="mapContainer"></div>
<div class="map-resize-handle" id="mapResizeHandle"></div>
<div class="panel-col-resize-handle" id="mapColResizeHandle"></div>`;
mapSection.classList.add('span-2');
mapSection.style.gridColumn = '1 / -1';
grid.appendChild(mapSection);
makeDraggable(mapSection, 'map');
attachPanelResize(mapSection, mapSection.querySelector('#mapResizeHandle')!, {
minSpan: 1,
maxSpan: 4,
rowPx: 200,
getStartSpan: () => currentSpan(mapSection),
onPreview: (span) => {
mapSection.classList.remove('span-1', 'span-2', 'span-3', 'span-4');
mapSection.classList.add(`span-${span}`);
},
onCommit: () => {
/* span persisted via Panel's store elsewhere; not needed here */
},
});
attachPanelColResize(mapSection, mapSection.querySelector('#mapColResizeHandle')!, {
getGrid: () => grid,
getStartCols: () => {
const m = mapSection.style.gridColumn.match(/span\s+(\d+)/);
return m && m[1] ? parseInt(m[1], 10) : 99;
},
onPreview: (cols, total) => {
mapSection.style.gridColumn = cols >= total ? '1 / -1' : `span ${cols}`;
},
onCommit: () => {
/* map cols persisted by App in prod; the harness only needs the geometry */
},
});
// ── Real Panel instances (own their bottom / right / corner grips) ──
for (const spec of PANELS) {
const panel = new Panel({ id: spec.id, title: spec.title, trackActivity: false });
const el = panel.getElement();
panel.setContent(`<div style="padding:10px;color:var(--text-dim)">${spec.title}</div>`);
makeDraggable(el, spec.id);
grid.appendChild(el);
}
const rectOf = (id: string): Rect | null => {
const el = grid.querySelector<HTMLElement>(`[data-panel="${id}"]`);
if (!el) return null;
const g = grid.getBoundingClientRect();
const r = el.getBoundingClientRect();
return { left: r.left - g.left, top: r.top - g.top, width: r.width, height: r.height };
};
const colStep = (): { step: number; cols: number; padL: number } => {
const cs = getComputedStyle(grid);
const tracks = cs.gridTemplateColumns.split(' ').filter(Boolean);
const n = Math.max(1, tracks.length);
const gap = parseFloat(cs.columnGap || '0') || 0;
const rect = grid.getBoundingClientRect();
const padL = parseFloat(cs.paddingLeft || '0') || 0;
const padR = parseFloat(cs.paddingRight || '0') || 0;
const inner = rect.width - padL - padR;
const colW = (inner - gap * (n - 1)) / n;
return { step: colW + gap, cols: n, padL };
};
window.__layoutHarness = {
ready: true,
mode: () => getLayoutMode(),
setMode: (m) => setLayoutMode(m),
toggle: () => toggleLayoutMode(),
cell: () => getCellSize(),
setCell: (px) => setCellSize(px),
rect: rectOf,
colStep,
order: () => Array.from(grid.children).map((c) => (c as HTMLElement).dataset.panel ?? ''),
overlayVisible: () => document.body.classList.contains('layout-snapping'),
gridColumnOf: (id) =>
grid.querySelector<HTMLElement>(`[data-panel="${id}"]`)?.style.gridColumn ?? '',
};
}
build();
+389
View File
@@ -0,0 +1,389 @@
// Layout engine: two modes for the panel grid, one mechanism, one config store.
//
// • "grid" (default) — the existing CSS-Grid reflow. Panels flow into cells;
// drag reorders (FLIP), resize snaps to grid tracks. cellSize drives the
// track floor (`--panel-col-min`), so changing it re-snaps every panel. A faint
// overlay of the live tracks is shown only while dragging/resizing.
// • "free" — pixel-perfect. The grid becomes a plain positioned block and each
// panel is absolutely placed at its own {x,y,w,h}; drag follows the cursor,
// resize is exact, nothing snaps, no overlay.
//
// This module owns ONLY the mode/config state, its persistence (per-variant),
// and the DOM application of a panel's saved geometry. All pointer math lives in
// panel-drag.ts, which reads getLayoutMode()/getCellSize() and calls back here to
// persist. Grid mode is left byte-for-byte untouched so it can never regress.
import { SITE_VARIANT } from '../config/variant';
export type LayoutMode = 'grid' | 'free';
export interface GridConfig {
enabled: boolean;
cellSize: number;
gap: number;
}
export interface FreeRect {
x: number;
y: number;
w: number;
h: number;
}
interface LayoutState {
mode: LayoutMode;
cellSize: number;
free: Record<string, FreeRect>;
gridCols: Record<string, number>;
}
// Per-variant so the tech / finance / full dashboards each keep their own layout.
const STORAGE_KEY = `worldmonitor-layout:${SITE_VARIANT}`;
// px — the grid column-track floor, exposed to CSS as `--panel-col-min` (the
// variable the base .panels-grid rule and the footer dock already read). 160
// matches the pre-existing default exactly, so the grid is byte-identical until
// the slider moves it — fixed-span panels (the live-news video, .panel-wide) are
// never shrunk by default. Range mirrors the dock slider (140360).
export const DEFAULT_CELL_SIZE = 160;
const DEFAULT_GAP = 4; // matches .panels-grid gap
const MIN_CELL_SIZE = 140;
const MAX_CELL_SIZE = 360;
// The CSS custom property that drives the grid column floor. Owned by the base
// .panels-grid rule (origin/main); the dock's fallback sets the same one — one
// variable, one way. grid-config is the source of truth when window.worldGrid is
// present.
const CELL_VAR = '--panel-col-min';
// Fired on document whenever the mode or cell size changes, so the toolbar can
// keep its toggle/slider in sync with programmatic (analyst / reset) changes.
export const LAYOUT_MODE_EVENT = 'layout-mode-change';
export const LAYOUT_CELL_EVENT = 'layout-cell-change';
const GRID_SELECTOR = '.panels-grid';
function readState(): LayoutState {
const base: LayoutState = { mode: 'grid', cellSize: DEFAULT_CELL_SIZE, free: {}, gridCols: {} };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return base;
const parsed = JSON.parse(raw) as Partial<LayoutState>;
return {
mode: parsed.mode === 'free' ? 'free' : 'grid',
cellSize:
typeof parsed.cellSize === 'number'
? clampCell(parsed.cellSize)
: DEFAULT_CELL_SIZE,
free: parsed.free && typeof parsed.free === 'object' ? parsed.free : {},
gridCols: parsed.gridCols && typeof parsed.gridCols === 'object' ? parsed.gridCols : {},
};
} catch {
return base;
}
}
function writeState(next: LayoutState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* private mode — layout simply won't persist */
}
}
function clampCell(px: number): number {
return Math.min(MAX_CELL_SIZE, Math.max(MIN_CELL_SIZE, Math.round(px)));
}
let state = readState();
function grid(): HTMLElement | null {
return document.querySelector<HTMLElement>(GRID_SELECTOR);
}
function panelsIn(g: HTMLElement): HTMLElement[] {
return Array.from(g.children).filter(
(c): c is HTMLElement => c instanceof HTMLElement && c.classList.contains('panel'),
);
}
// ── public config API (also consumed by the toolbar agent) ──────────────────
export function getLayoutMode(): LayoutMode {
return state.mode;
}
export function getGridConfig(): GridConfig {
return { enabled: state.mode === 'grid', cellSize: state.cellSize, gap: DEFAULT_GAP };
}
export function getCellSize(): number {
return state.cellSize;
}
export function getGap(): number {
return DEFAULT_GAP;
}
/** Switch layout mode: persist, restyle the body, re-lay out every panel. */
export function setLayoutMode(mode: LayoutMode): void {
if (mode !== 'grid' && mode !== 'free') return;
if (mode === state.mode) {
applyLayout();
return;
}
const g = grid();
// Entering free mode: snapshot the current GRID geometry BEFORE the body flips
// to a block container. Otherwise the still-relative panels collapse to block
// flow and we'd freeze the wrong positions (the map balloons to full-viewport).
const frozen = mode === 'free' && g ? freezeAll(g) : null;
state = { ...state, mode };
writeState(state);
applyBodyMode();
applyCellVar();
if (g) {
for (const el of panelsIn(g)) applyPanelInMode(g, el, frozen?.get(el));
updateContainerHeight(g);
}
document.dispatchEvent(new CustomEvent(LAYOUT_MODE_EVENT, { detail: { mode } }));
}
export function toggleLayoutMode(): LayoutMode {
const next: LayoutMode = state.mode === 'grid' ? 'free' : 'grid';
setLayoutMode(next);
return next;
}
/** Set the grid cell size (px). Grid mode re-snaps as tracks resize. Persisted. */
export function setCellSize(px: number): void {
const cell = clampCell(px);
if (cell === state.cellSize) return;
state = { ...state, cellSize: cell };
writeState(state);
applyCellVar();
applyLayout();
document.dispatchEvent(new CustomEvent(LAYOUT_CELL_EVENT, { detail: { cellSize: cell } }));
}
/** Clear every layout-engine customization (mode, cell size, free + grid geometry). */
export function resetLayout(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
state = { mode: 'grid', cellSize: DEFAULT_CELL_SIZE, free: {}, gridCols: {} };
}
// ── per-panel geometry persistence (used by panel-drag) ─────────────────────
export function getFreeRect(id: string): FreeRect | undefined {
return state.free[id];
}
export function setFreeRect(id: string, rect: FreeRect): void {
state = { ...state, free: { ...state.free, [id]: rect } };
writeState(state);
}
export function getGridCols(id: string): number | undefined {
return state.gridCols[id];
}
export function setGridCols(id: string, cols: number): void {
const next = { ...state.gridCols };
if (cols > 1) next[id] = cols;
else delete next[id];
state = { ...state, gridCols: next };
writeState(state);
}
// ── DOM application ─────────────────────────────────────────────────────────
function applyBodyMode(): void {
const b = document.body;
if (!b) return;
b.classList.toggle('layout-free', state.mode === 'free');
b.classList.toggle('layout-grid', state.mode === 'grid');
}
function applyCellVar(): void {
document.documentElement.style.setProperty(CELL_VAR, `${state.cellSize}px`);
}
/** Current geometry of a panel expressed relative to the grid's padding box. */
function measureRect(g: HTMLElement, el: HTMLElement): FreeRect {
const gr = g.getBoundingClientRect();
const er = el.getBoundingClientRect();
return {
x: Math.max(0, Math.round(er.left - gr.left - g.clientLeft)),
y: Math.max(0, Math.round(er.top - gr.top - g.clientTop)),
w: Math.round(er.width),
h: Math.round(er.height),
};
}
function applyFreeRect(el: HTMLElement, rect: FreeRect): void {
el.style.position = 'absolute';
el.style.left = `${rect.x}px`;
el.style.top = `${rect.y}px`;
el.style.width = `${rect.w}px`;
el.style.height = `${rect.h}px`;
}
function clearFreeInline(el: HTMLElement): void {
el.style.position = '';
el.style.left = '';
el.style.top = '';
el.style.width = '';
el.style.height = '';
}
/**
* Apply the active mode to a single panel. In free mode: place it at its saved
* rect, or — on the first-ever switch — freeze it exactly where the grid put it
* (measured by the caller and passed in) so the transition never jumps. In grid
* mode: strip any free inline geometry and re-apply a saved column span.
*/
function applyPanelInMode(g: HTMLElement, el: HTMLElement, frozen?: FreeRect): void {
const id = el.dataset.panel;
if (!id) return;
if (state.mode === 'free') {
let rect = getFreeRect(id);
if (!rect) {
rect = frozen ?? measureRect(g, el);
setFreeRect(id, rect);
}
applyFreeRect(el, rect);
} else {
clearFreeInline(el);
// The map owns its own gridColumn (full-width anchor / half-width) in App.
if (id !== 'map') {
const cols = getGridCols(id);
if (cols && cols > 1) el.style.gridColumn = `span ${cols}`;
else if (el.style.gridColumn) el.style.gridColumn = '';
}
}
}
/** Grow the block container in free mode to contain its absolutely-placed panels. */
function updateContainerHeight(g: HTMLElement): void {
if (state.mode !== 'free') {
g.style.height = '';
return;
}
let bottom = 0;
for (const el of panelsIn(g)) {
if (el.classList.contains('hidden')) continue;
bottom = Math.max(bottom, el.offsetTop + el.offsetHeight);
}
g.style.height = `${bottom + DEFAULT_GAP}px`;
}
/** Snapshot every panel's current geometry (measured in whatever layout is live). */
function freezeAll(g: HTMLElement): Map<HTMLElement, FreeRect> {
const m = new Map<HTMLElement, FreeRect>();
for (const el of panelsIn(g)) m.set(el, measureRect(g, el));
return m;
}
/** Re-apply the current mode to every panel using saved geometry (cell change,
* reset, same-mode refresh). Never re-measures — the mode switch owns freezing. */
export function applyLayout(): void {
const g = grid();
if (!g) return;
applyCellVar();
for (const el of panelsIn(g)) applyPanelInMode(g, el);
updateContainerHeight(g);
}
/** Called by panel-drag when a panel mounts, so it self-applies its geometry. */
export function registerPanel(el: HTMLElement): void {
// el is attached to the grid synchronously after this call; defer one microtask
// so measureRect/position see the panel in the DOM.
queueMicrotask(() => {
const g = grid();
if (!g || el.parentElement !== g) return;
applyPanelInMode(g, el);
scheduleHeight(g);
});
}
/** Called by panel-drag after a free drag/resize commits new geometry. */
export function commitFreeRect(el: HTMLElement, rect: FreeRect): void {
const id = el.dataset.panel;
if (!id) return;
setFreeRect(id, rect);
const g = grid();
if (g) scheduleHeight(g);
}
let heightRaf = 0;
function scheduleHeight(g: HTMLElement): void {
if (heightRaf) return;
heightRaf = requestAnimationFrame(() => {
heightRaf = 0;
updateContainerHeight(g);
});
}
// ── snap overlay (grid mode only, transient during drag/resize) ─────────────
/** Show the faint track overlay. No-op in free mode. */
export function showSnapOverlay(): void {
if (state.mode !== 'grid') return;
const g = grid();
if (!g) return;
const cs = getComputedStyle(g);
const cols = cs.gridTemplateColumns.split(' ').filter(Boolean);
const gap = parseFloat(cs.columnGap || cs.gap || '0') || 0;
const colW = cols.length ? parseFloat(cols[0]!) || state.cellSize : state.cellSize;
const rowGap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
const rows = cs.gridAutoRows.split(' ').filter(Boolean);
const rowH = rows.length ? parseFloat(rows[0]!) || state.cellSize : state.cellSize;
const padLeft = parseFloat(cs.paddingLeft || '0') || 0;
const padTop = parseFloat(cs.paddingTop || '0') || 0;
g.style.setProperty('--grid-overlay-col', `${colW + gap}px`);
g.style.setProperty('--grid-overlay-row', `${rowH + rowGap}px`);
g.style.setProperty('--grid-overlay-x', `${padLeft}px`);
g.style.setProperty('--grid-overlay-y', `${padTop}px`);
document.body.classList.add('layout-snapping');
}
export function hideSnapOverlay(): void {
document.body.classList.remove('layout-snapping');
}
// ── bootstrap ───────────────────────────────────────────────────────────────
function bootstrap(): void {
applyBodyMode();
applyCellVar();
const g = grid();
if (g && panelsIn(g).length) applyLayout();
}
// The footer dock's layout controls (App.gridApi) delegate to this global when
// present, so the Grid⇄Free toggle + cell-size slider drive the real engine
// rather than the dock's minimal CSS-var fallback. One way to do everything.
if (typeof window !== 'undefined') {
(window as unknown as { worldGrid?: unknown }).worldGrid = {
setLayoutMode, getLayoutMode, toggleLayoutMode,
setCellSize, getCellSize, getGridConfig, resetLayout,
};
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
bootstrap();
}
// Reset from the settings modal button / analyst request also clears our keys,
// then App reloads. Both are synchronous, so removeItem lands before navigation.
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement | null;
if (t?.closest('#resetLayoutBtn')) resetLayout();
});
document.addEventListener('panel-reset-layout-request', () => resetLayout());
}
+399 -31
View File
@@ -3,26 +3,92 @@
// One module owns all pointer math and drag/resize visuals; callers own state
// (App persists panel order, Panel owns the row-span class ↔ storage mapping).
//
// Drag: unified Pointer Events (mouse + touch + pen), a small press threshold so
// clicks and scrolls are never hijacked, a custom translucent ghost that follows
// the pointer, and a live gap that opens where the panel will land — sibling
// panels slide into their new positions with a transform-based FLIP animation
// (no layout jank, no OS drag image). Escape cancels and restores.
// The module is layout-mode aware (see services/grid-config.ts):
// • "grid" mode — the original behaviour, untouched. Drag reorders panels with
// a custom ghost + FLIP reflow; resize snaps to grid tracks / row-spans. A
// faint track overlay is shown for the duration of the gesture.
// • "free" mode — the grid is a positioned block and each panel is absolutely
// placed. Drag moves the panel directly under the cursor; resize is
// pixel-exact from the right edge, bottom edge, or bottom-right corner.
// Nothing snaps; the new {x,y,w,h} is persisted via grid-config.
//
// Resize: the same pointer plumbing drives the bottom handle. Height maps to a
// discrete grid row-span on a clean rowPx grid, so the snap points line up with
// the cursor instead of the old mismatched thresholds.
// Drag (grid): unified Pointer Events (mouse + touch + pen), a small press
// threshold so clicks and scrolls are never hijacked, a custom translucent ghost
// that follows the pointer, and a live gap that opens where the panel will land.
// Escape cancels and restores.
//
// Resize (grid): the same pointer plumbing drives the handles. Height maps to a
// discrete grid row-span on a clean rowPx grid; width maps to a column span on
// the live track grid, so the snap points line up with the cursor.
import {
getLayoutMode,
registerPanel,
commitFreeRect,
showSnapOverlay,
hideSnapOverlay,
} from './grid-config';
const DRAG_THRESHOLD = 6; // px of pointer travel before a press becomes a drag
const FLIP_MS = 180; // sibling reflow duration
const DROP_SETTLE_MS = 160; // ghost easing into its final slot on release
const FLIP_EASE = 'cubic-bezier(0.2, 0, 0, 1)';
// Sane pixel floors for free-mode geometry. The map keeps a larger floor so it
// never collapses to an unreadable sliver.
const FREE_MIN_W = 160;
const FREE_MIN_H = 120;
const MAP_MIN = 240;
const minWidthFor = (el: HTMLElement, opt?: number): number =>
el.classList.contains('map-panel') ? MAP_MIN : opt ?? FREE_MIN_W;
const minHeightFor = (el: HTMLElement, opt?: number): number =>
el.classList.contains('map-panel') ? MAP_MIN : opt ?? FREE_MIN_H;
type ResizeAxis = 'x' | 'y' | 'xy';
// Free-mode pixel resize, shared by the right / bottom / corner handles. The
// panel is top-left anchored (absolute), so only its width/height change.
interface FreeResizeState {
startX: number;
startY: number;
startW: number;
startH: number;
minW: number;
minH: number;
}
function applyFreeResize(
el: HTMLElement,
axis: ResizeAxis,
s: FreeResizeState,
clientX: number,
clientY: number,
): void {
if (axis === 'x' || axis === 'xy') {
const w = Math.max(s.minW, s.startW + (clientX - s.startX));
el.style.width = `${Math.round(w)}px`;
}
if (axis === 'y' || axis === 'xy') {
const h = Math.max(s.minH, s.startH + (clientY - s.startY));
el.style.height = `${Math.round(h)}px`;
}
}
function commitFreeGeometry(el: HTMLElement): void {
commitFreeRect(el, {
x: el.offsetLeft,
y: el.offsetTop,
w: el.offsetWidth,
h: el.offsetHeight,
});
}
const isInteractive = (target: Element | null): boolean =>
!!target?.closest('button, a, input, select, textarea, [contenteditable="true"]');
const isResizeHandle = (target: Element | null): boolean =>
!!target?.closest('.panel-resize-handle, .panel-col-resize-handle');
!!target?.closest('.panel-resize-handle, .panel-col-resize-handle, .panel-corner-resize-handle');
/** Panels that participate in reflow — everything in the grid that is a visible panel. */
function livePanels(grid: HTMLElement, except?: HTMLElement): HTMLElement[] {
@@ -71,6 +137,12 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
let lastY = 0;
let onKeyDown: ((e: KeyboardEvent) => void) | null = null;
// Free-mode drag bookkeeping: the panel is already position:absolute, so a drag
// just tracks its top-left under the cursor (no ghost, no reflow).
let freeGesture = false; // this gesture started in free mode
let freeStartLeft = 0;
let freeStartTop = 0;
const clearFlip = (g: HTMLElement) => {
for (const p of livePanels(g)) {
p.style.transition = '';
@@ -176,20 +248,53 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
grid = opts.getGrid();
if (!grid) return;
dragging = true;
originalNext = el.nextSibling;
ghost = makeGhost();
el.classList.add('panel-drag-source');
document.body.classList.add('panel-drag-active');
onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') cancelDrag();
};
document.addEventListener('keydown', onKeyDown, true);
if (freeGesture) {
// Pixel-follow drag: lift the panel above its siblings, no ghost/FLIP.
freeStartLeft = el.offsetLeft;
freeStartTop = el.offsetTop;
el.classList.add('panel-free-dragging');
document.body.classList.add('panel-drag-active');
return;
}
originalNext = el.nextSibling;
ghost = makeGhost();
el.classList.add('panel-drag-source');
document.body.classList.add('panel-drag-active');
showSnapOverlay();
};
// Free-mode: place the panel's top-left under the pointer, clamped into the
// grid, then persist. Grid origin is re-read each frame so page scroll during
// a drag never introduces drift.
const moveFree = (x: number, y: number) => {
if (!grid) return;
const gr = grid.getBoundingClientRect();
const left = Math.max(0, x - offsetX - gr.left - grid.clientLeft);
const top = Math.max(0, y - offsetY - gr.top - grid.clientTop);
el.style.left = `${Math.round(left)}px`;
el.style.top = `${Math.round(top)}px`;
};
const commitFree = () => {
commitFreeRect(el, {
x: el.offsetLeft,
y: el.offsetTop,
w: el.offsetWidth,
h: el.offsetHeight,
});
};
const finishVisuals = () => {
if (grid) clearFlip(grid);
el.classList.remove('panel-drag-source');
el.classList.remove('panel-drag-source', 'panel-free-dragging');
document.body.classList.remove('panel-drag-active');
hideSnapOverlay();
if (onKeyDown) {
document.removeEventListener('keydown', onKeyDown, true);
onKeyDown = null;
@@ -220,6 +325,13 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
cancelAnimationFrame(rafId);
rafId = 0;
}
if (freeGesture) {
el.style.left = `${freeStartLeft}px`; // restore original position
el.style.top = `${freeStartTop}px`;
finishVisuals();
releasePointer();
return;
}
if (grid) grid.insertBefore(el, originalNext); // restore original slot
finishVisuals();
removeGhost();
@@ -266,6 +378,10 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
rafId = requestAnimationFrame(() => {
rafId = 0;
if (!dragging || !grid) return;
if (freeGesture) {
moveFree(x, y);
return;
}
moveGhost(x, y);
const ref = referenceNodeAt(grid, x, y);
if (ref !== undefined) reorderTo(grid, ref);
@@ -279,7 +395,12 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
cancelAnimationFrame(rafId);
rafId = 0;
}
if (wasDragging && grid) {
if (wasDragging && freeGesture) {
moveFree(lastX, lastY);
dragging = false;
finishVisuals();
commitFree();
} else if (wasDragging && grid) {
const ref = referenceNodeAt(grid, lastX, lastY);
if (ref !== undefined) reorderTo(grid, ref);
const slot = el.getBoundingClientRect();
@@ -313,6 +434,7 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
pointerId = e.pointerId;
pressing = true;
dragging = false;
freeGesture = getLayoutMode() === 'free';
startX = e.clientX;
startY = e.clientY;
const rect = el.getBoundingClientRect();
@@ -326,6 +448,9 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
el.addEventListener('pointerdown', onPointerDown);
// Self-apply this panel's saved geometry for the active mode once it mounts.
registerPanel(el);
return () => {
if (dragging) cancelDrag();
else releasePointer();
@@ -345,6 +470,8 @@ export interface PanelResizeOptions {
* (span-0 = 120px tiny … span-4 = 800px) with a ~100px feel at the small end.
*/
snapHeights?: number[];
/** Pixel floor for free-mode height (default 120). */
minH?: number;
/** Height at drag start → the panel's current span. */
getStartSpan: () => number;
/** Live: apply the given span while dragging (Panel owns the span→class mapping). */
@@ -355,8 +482,8 @@ export interface PanelResizeOptions {
/**
* Drive a panel's bottom resize handle with the same pointer plumbing as drag.
* Height snaps to a discrete row-span on a clean rowPx grid, so snap points line
* up with the cursor. Returns a cleanup fn.
* In grid mode height snaps to a discrete row-span on a clean rowPx grid; in free
* mode it is pixel-exact and persisted via grid-config. Returns a cleanup fn.
*/
export function attachPanelResize(
el: HTMLElement,
@@ -373,6 +500,8 @@ export function attachPanelResize(
let startY = 0;
let startHeight = 0;
let lastSpan = minSpan;
let freeGesture = false;
let free: FreeResizeState | null = null;
const spanFor = (height: number): number => {
if (snapHeights && snapHeights.length > 0) {
@@ -395,6 +524,10 @@ export function attachPanelResize(
const onPointerMove = (e: PointerEvent) => {
if (!resizing || e.pointerId !== pointerId) return;
e.preventDefault();
if (freeGesture && free) {
applyFreeResize(el, 'y', free, e.clientX, e.clientY);
return;
}
const height = startHeight + (e.clientY - startY);
const span = spanFor(height);
if (span !== lastSpan) {
@@ -420,7 +553,12 @@ export function attachPanelResize(
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
opts.onCommit(lastSpan);
if (freeGesture) {
commitFreeGeometry(el);
} else {
hideSnapOverlay();
opts.onCommit(lastSpan);
}
};
const onPointerUp = (e: PointerEvent) => {
@@ -434,9 +572,23 @@ export function attachPanelResize(
e.stopPropagation();
pointerId = e.pointerId;
resizing = true;
freeGesture = getLayoutMode() === 'free';
const rect = el.getBoundingClientRect();
startY = e.clientY;
startHeight = el.getBoundingClientRect().height;
startHeight = rect.height;
lastSpan = opts.getStartSpan();
if (freeGesture) {
free = {
startX: e.clientX,
startY: e.clientY,
startW: rect.width,
startH: rect.height,
minW: minWidthFor(el),
minH: minHeightFor(el, opts.minH),
};
} else {
showSnapOverlay();
}
el.classList.add('resizing');
el.dataset.resizing = 'true';
handle.classList.add('active');
@@ -463,6 +615,8 @@ export interface PanelColResizeOptions {
getGrid: () => HTMLElement | null;
/** Column span at drag start. */
getStartCols: () => number;
/** Pixel floor for free-mode width (default 160). */
minW?: number;
/** Live preview: apply a span of `cols` out of `total` grid columns. */
onPreview: (cols: number, total: number) => void;
/** Persist the final span (cols out of total). */
@@ -470,11 +624,10 @@ export interface PanelColResizeOptions {
}
/**
* Horizontal sibling of attachPanelResize: drag a panel's right edge to set how
* many grid COLUMNS it spans, snapping to the live `repeat(auto-fill, …)` track
* so a panel (the map) can be pulled down to half width and let others flow in
* beside it. The module owns pointer math + column snapping; the caller owns the
* cols→style mapping and persistence.
* Horizontal sibling of attachPanelResize: drag a panel's right edge. In grid
* mode it sets how many grid COLUMNS the panel spans, snapping to the live
* `repeat(auto-fill, …)` track. In free mode it sets a pixel width, persisted via
* grid-config. The module owns pointer math; the caller owns the cols→style map.
*/
export function attachPanelColResize(
el: HTMLElement,
@@ -485,10 +638,14 @@ export function attachPanelColResize(
let resizing = false;
let total = 1;
let colStep = 1;
let gridLeft = 0;
let originLeft = 0; // the panel's own left edge — span is measured from here
let lastCols = 1;
let freeGesture = false;
let free: FreeResizeState | null = null;
// Read the live column geometry: track count + (track width + gap) as the step.
// The span origin is the panel's own left edge, so a mid-grid panel snaps to the
// right number of columns (for the col-0 map this equals the grid's left edge).
const measure = (grid: HTMLElement): void => {
const style = getComputedStyle(grid);
const tracks = style.gridTemplateColumns.split(' ').filter(Boolean);
@@ -500,18 +657,22 @@ export function attachPanelColResize(
const inner = rect.width - padLeft - padRight;
const colW = (inner - gap * (total - 1)) / total;
colStep = colW + gap;
gridLeft = rect.left + padLeft;
originLeft = el.getBoundingClientRect().left;
};
const colsFor = (clientX: number): number => {
if (colStep <= 0) return total;
const cols = Math.round((clientX - gridLeft) / colStep);
const cols = Math.round((clientX - originLeft) / colStep);
return Math.min(total, Math.max(1, cols));
};
const onPointerMove = (e: PointerEvent) => {
if (!resizing || e.pointerId !== pointerId) return;
e.preventDefault();
if (freeGesture && free) {
applyFreeResize(el, 'x', free, e.clientX, e.clientY);
return;
}
const cols = colsFor(e.clientX);
if (cols !== lastCols) {
lastCols = cols;
@@ -536,7 +697,12 @@ export function attachPanelColResize(
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
opts.onCommit(lastCols, total);
if (freeGesture) {
commitFreeGeometry(el);
} else {
hideSnapOverlay();
opts.onCommit(lastCols, total);
}
};
const onPointerUp = (e: PointerEvent) => {
@@ -547,13 +713,30 @@ export function attachPanelColResize(
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const grid = opts.getGrid();
if (!grid) return;
e.preventDefault();
e.stopPropagation();
measure(grid);
pointerId = e.pointerId;
resizing = true;
lastCols = opts.getStartCols();
freeGesture = getLayoutMode() === 'free';
if (freeGesture) {
const rect = el.getBoundingClientRect();
free = {
startX: e.clientX,
startY: e.clientY,
startW: rect.width,
startH: rect.height,
minW: minWidthFor(el, opts.minW),
minH: minHeightFor(el),
};
} else {
if (!grid) {
resizing = false;
return;
}
measure(grid);
lastCols = opts.getStartCols();
showSnapOverlay();
}
el.classList.add('resizing-col');
el.dataset.resizing = 'true';
handle.classList.add('active');
@@ -574,3 +757,188 @@ export function attachPanelColResize(
handle.removeEventListener('pointerdown', onPointerDown);
};
}
export interface PanelCornerResizeOptions {
/** Resolve the live grid (grid mode: its tracks drive the column snap). */
getGrid: () => HTMLElement | null;
/** Row-span at drag start. */
getStartSpan: () => number;
/** Column-span at drag start. */
getStartCols: () => number;
/** Per-span target heights (index === span) for the vertical snap. */
snapHeights?: number[];
minSpan?: number; // default 0
maxSpan?: number; // default 4
minW?: number; // free-mode width floor (default 160)
minH?: number; // free-mode height floor (default 120)
/** Grid preview: apply row-span. */
onPreviewSpan: (span: number) => void;
/** Grid preview: apply column-span (cols out of total). */
onPreviewCols: (cols: number, total: number) => void;
/** Grid commit: persist row-span. */
onCommitSpan: (span: number) => void;
/** Grid commit: persist column-span (cols out of total). */
onCommitCols: (cols: number, total: number) => void;
}
/**
* Bottom-right corner handle: resizes width AND height at once. In grid mode it
* drives both the column-span and row-span snaps (reusing the exact track /
* tier math of the edge handles); in free mode it is a pixel resize on both
* axes. One gesture, both dimensions.
*/
export function attachPanelCornerResize(
el: HTMLElement,
handle: HTMLElement,
opts: PanelCornerResizeOptions,
): () => void {
const minSpan = opts.minSpan ?? 0;
const maxSpan = opts.maxSpan ?? 4;
const snapHeights = opts.snapHeights;
let pointerId: number | null = null;
let resizing = false;
let freeGesture = false;
let free: FreeResizeState | null = null;
// Grid geometry.
let total = 1;
let colStep = 1;
let originLeft = 0; // the panel's own left edge — span origin
let startTop = 0;
let lastCols = 1;
let lastSpan = minSpan;
const measure = (grid: HTMLElement): void => {
const style = getComputedStyle(grid);
const tracks = style.gridTemplateColumns.split(' ').filter(Boolean);
total = Math.max(1, tracks.length);
const gap = parseFloat(style.columnGap || '0') || 0;
const rect = grid.getBoundingClientRect();
const padLeft = parseFloat(style.paddingLeft || '0') || 0;
const padRight = parseFloat(style.paddingRight || '0') || 0;
const inner = rect.width - padLeft - padRight;
const colW = (inner - gap * (total - 1)) / total;
colStep = colW + gap;
originLeft = el.getBoundingClientRect().left;
};
const colsFor = (clientX: number): number => {
if (colStep <= 0) return total;
return Math.min(total, Math.max(1, Math.round((clientX - originLeft) / colStep)));
};
const spanFor = (height: number): number => {
if (snapHeights && snapHeights.length > 0) {
let best = minSpan;
let bestDist = Infinity;
for (let span = minSpan; span <= maxSpan && span < snapHeights.length; span++) {
const dist = Math.abs(height - snapHeights[span]!);
if (dist < bestDist) {
bestDist = dist;
best = span;
}
}
return best;
}
return Math.min(maxSpan, Math.max(minSpan, Math.round(height / 200)));
};
const onPointerMove = (e: PointerEvent) => {
if (!resizing || e.pointerId !== pointerId) return;
e.preventDefault();
if (freeGesture && free) {
applyFreeResize(el, 'xy', free, e.clientX, e.clientY);
return;
}
const cols = colsFor(e.clientX);
if (cols !== lastCols) {
lastCols = cols;
opts.onPreviewCols(cols, total);
}
const span = spanFor(e.clientY - startTop);
if (span !== lastSpan) {
lastSpan = span;
opts.onPreviewSpan(span);
}
};
const end = () => {
if (!resizing) return;
resizing = false;
el.classList.remove('resizing', 'resizing-col');
handle.classList.remove('active');
delete el.dataset.resizing;
if (pointerId !== null) {
try {
handle.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
}
pointerId = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
if (freeGesture) {
commitFreeGeometry(el);
} else {
hideSnapOverlay();
opts.onCommitCols(lastCols, total);
opts.onCommitSpan(lastSpan);
}
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
end();
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const grid = opts.getGrid();
e.preventDefault();
e.stopPropagation();
pointerId = e.pointerId;
resizing = true;
freeGesture = getLayoutMode() === 'free';
const rect = el.getBoundingClientRect();
if (freeGesture) {
free = {
startX: e.clientX,
startY: e.clientY,
startW: rect.width,
startH: rect.height,
minW: minWidthFor(el, opts.minW),
minH: minHeightFor(el, opts.minH),
};
} else {
if (!grid) {
resizing = false;
return;
}
measure(grid);
startTop = rect.top;
lastCols = opts.getStartCols();
lastSpan = opts.getStartSpan();
showSnapOverlay();
}
el.classList.add('resizing', 'resizing-col');
el.dataset.resizing = 'true';
handle.classList.add('active');
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
handle.addEventListener('pointerdown', onPointerDown);
return () => {
end();
handle.removeEventListener('pointerdown', onPointerDown);
};
}
+122
View File
@@ -16798,3 +16798,125 @@ body.immersive .immersive-collapse-btn { display: inline-flex; }
.dock-slider input[type="range"] { width: 64px; }
.dock-group { padding-right: 5px; gap: 5px; }
}
/*
LAYOUT ENGINE (services/grid-config.ts + services/panel-drag.ts)
Free-form pixel positioning + configurable snap-to-grid. APPEND-ONLY section:
grid mode above is untouched. Two body states: .layout-grid (default, CSS-grid
reflow) and .layout-free (absolute per-panel {x,y,w,h}). .layout-snapping is
transient during a grid-mode drag/resize and shows the faint track overlay.
Cell size is the base .panels-grid rule's --panel-col-min (default 160px),
driven by grid-config / the footer dock one variable, one way. Wide panels
(live-news video, .panel-wide) are never capped: they span up to full width.
*/
/* ── Bottom-right corner resize grip (all panels) ────────────────────────── */
.panel-corner-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 160; /* above the bottom (100) + right (150) edge handles */
touch-action: none;
user-select: none;
opacity: 0.32;
transition: opacity 0.15s ease;
}
.panel-corner-resize-handle::after {
content: '';
position: absolute;
right: 3px;
bottom: 3px;
width: 8px;
height: 8px;
border-right: 2px solid var(--text-dim);
border-bottom: 2px solid var(--text-dim);
transition: border-color 0.2s ease;
}
.panel:hover .panel-corner-resize-handle,
.map-section:hover .panel-corner-resize-handle,
.panel-corner-resize-handle.active {
opacity: 1;
}
.panel-corner-resize-handle:hover::after,
.panel-corner-resize-handle.active::after {
border-color: var(--accent);
}
/* Regular panels now carry the right-edge width grip too (was map-only); keep it
quiet until hover, same as the other grips. */
.panel:hover .panel-col-resize-handle,
.panel-col-resize-handle.active {
opacity: 1;
}
/* ── Grid-mode snap overlay (transient, during drag/resize only) ─────────── */
body.layout-grid.layout-snapping .panels-grid::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4; /* above panels, below the fixed drag ghost (10000) */
background-image:
linear-gradient(to right, var(--border) 1px, transparent 1px),
linear-gradient(to bottom, var(--border) 1px, transparent 1px);
background-size: var(--grid-overlay-col, 160px) var(--grid-overlay-row, 160px);
background-position: var(--grid-overlay-x, 4px) var(--grid-overlay-y, 4px);
opacity: 0.35;
animation: layoutOverlayFade 0.14s ease-out;
}
@keyframes layoutOverlayFade {
from { opacity: 0; }
to { opacity: 0.35; }
}
/* ── Free mode: absolute per-panel positioning ───────────────────────────── */
body.layout-free .panels-grid {
display: block; /* leave CSS-grid flow; panels are absolutely placed */
position: relative; /* positioning context for the absolute panels */
/* height is set inline by grid-config to contain the placed panels */
}
/* Geometry (position/left/top/width/height) is applied inline by grid-config.
Here we only neutralise the grid-mode height floors so a free panel can be
made genuinely small (JS clamps to a sane 160×120 minimum instead). The map
keeps its larger 240px floor. */
body.layout-free .panel:not(.map-panel) {
min-height: 0 !important;
max-height: none !important;
}
/* The panel being dragged in free mode lifts above its neighbours. */
.panel.panel-free-dragging,
.map-section.panel-free-dragging {
z-index: 50;
cursor: grabbing;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
transition: none;
}
/* No snap overlay in free mode (guarded in JS too, but belt-and-braces). */
body.layout-free .panels-grid::after {
display: none;
}
/* Mobile is always the single-column flex layout, regardless of a desktop-saved
free mode: neutralise the inline absolute geometry so a carried-over mode can
never break the phone layout. Matches the mobile breakpoint (768px). */
@media (max-width: 768px) {
body.layout-free .panels-grid {
display: flex !important;
position: relative;
height: auto !important;
}
body.layout-free .panel,
body.layout-free .map-section {
position: static !important;
left: auto !important;
top: auto !important;
width: 100% !important;
height: auto !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layout Engine Harness</title>
</head>
<body>
<div class="main-content"><div id="app"></div></div>
<button class="panels-reset-btn" id="resetLayoutBtn" type="button" style="position:fixed;bottom:8px;right:8px;z-index:9999">Reset layout</button>
<script type="module" src="/src/e2e/layout-harness.ts"></script>
</body>
</html>