diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e9fc85 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Repository instructions + +## Local web preview + +- Do not validate this app by opening `index.html` with a `file://` URL. ES modules and the SwiftWasm bundle require an HTTP server, and a file URL will leave the editor stuck loading. +- After every implementation change, build the real browser artifacts and start a preview server for the user to inspect. Do not substitute a mock or stub for the SwiftWasm module. +- Use the installed Swift 6.2.3 toolchain that matches the repository's WebAssembly SDK: + + ```sh + TOOLCHAINS=org.swift.623202512101a swift package \ + --swift-sdk 6.2-SNAPSHOT-2026-01-12-a-wasm32-unknown-wasip1 \ + js -c release + npm ci + npm run preview + ``` + +- Keep the preview server running after verification so the user can open it. Report the complete HTTP URL, including the `/DisplayListExplorer/` base path. +- If the default preview port is occupied, reuse the existing server when it serves this worktree; otherwise choose another port and report it explicitly. diff --git a/index.html b/index.html index 83fb976..8309760 100644 --- a/index.html +++ b/index.html @@ -72,6 +72,19 @@

DisplayList Description

+ +
diff --git a/main.js b/main.js index 6bab309..a41980a 100644 --- a/main.js +++ b/main.js @@ -124,4 +124,126 @@ globalThis.displayListEditor = { }, }; +const explorer = document.querySelector(".explorer"); +const paneSplitter = document.getElementById("pane-splitter"); +const verticalLayout = window.matchMedia("(max-width: 980px)"); +const defaultPaneRatio = 0.5; +const minimumPaneRatio = 0.2; +const minimumPaneSize = 240; +let paneRatio = defaultPaneRatio; +let isResizingPanes = false; + +function isVerticalLayout() { + return verticalLayout.matches; +} + +function paneRatioBounds() { + const vertical = isVerticalLayout(); + const explorerSize = vertical ? explorer.clientHeight : explorer.clientWidth; + const splitterSize = vertical ? paneSplitter.offsetHeight : paneSplitter.offsetWidth; + const availableSize = Math.max(1, explorerSize - splitterSize); + const lowerBound = Math.max( + minimumPaneRatio, + Math.min(0.5, minimumPaneSize / availableSize), + ); + return { lower: lowerBound, upper: 1 - lowerBound }; +} + +function setPaneRatio(nextRatio) { + const { lower, upper } = paneRatioBounds(); + paneRatio = Math.min(upper, Math.max(lower, nextRatio)); + explorer.style.setProperty("--source-pane-share", `${paneRatio}fr`); + explorer.style.setProperty("--detail-pane-share", `${1 - paneRatio}fr`); + + const percentage = Math.round(paneRatio * 100); + paneSplitter.setAttribute("aria-valuemin", String(Math.round(lower * 100))); + paneSplitter.setAttribute("aria-valuemax", String(Math.round(upper * 100))); + paneSplitter.setAttribute("aria-valuenow", String(percentage)); + paneSplitter.setAttribute("aria-valuetext", `Source pane ${percentage}%`); +} + +function setPaneRatioFromPointer(event) { + const vertical = isVerticalLayout(); + const explorerRect = explorer.getBoundingClientRect(); + const splitterSize = vertical ? paneSplitter.offsetHeight : paneSplitter.offsetWidth; + const availableSize = Math.max( + 1, + (vertical ? explorerRect.height : explorerRect.width) - splitterSize, + ); + const pointerPosition = vertical + ? event.clientY - explorerRect.top + : event.clientX - explorerRect.left; + setPaneRatio((pointerPosition - splitterSize / 2) / availableSize); +} + +paneSplitter.addEventListener("pointerdown", (event) => { + if (event.button !== 0) { + return; + } + + isResizingPanes = true; + paneSplitter.setPointerCapture(event.pointerId); + paneSplitter.classList.add("is-dragging"); + document.body.classList.add("is-resizing-panes"); + setPaneRatioFromPointer(event); + event.preventDefault(); +}); + +paneSplitter.addEventListener("pointermove", (event) => { + if (isResizingPanes && paneSplitter.hasPointerCapture(event.pointerId)) { + setPaneRatioFromPointer(event); + } +}); + +function finishPaneResize(event) { + if (!isResizingPanes) { + return; + } + + isResizingPanes = false; + if (paneSplitter.hasPointerCapture(event.pointerId)) { + paneSplitter.releasePointerCapture(event.pointerId); + } + paneSplitter.classList.remove("is-dragging"); + document.body.classList.remove("is-resizing-panes"); +} + +paneSplitter.addEventListener("pointerup", finishPaneResize); +paneSplitter.addEventListener("pointercancel", finishPaneResize); +paneSplitter.addEventListener("lostpointercapture", finishPaneResize); +paneSplitter.addEventListener("dblclick", () => setPaneRatio(defaultPaneRatio)); + +paneSplitter.addEventListener("keydown", (event) => { + const vertical = isVerticalLayout(); + const decreaseKey = vertical ? "ArrowUp" : "ArrowLeft"; + const increaseKey = vertical ? "ArrowDown" : "ArrowRight"; + const step = event.shiftKey ? 0.1 : 0.02; + + if (event.key === decreaseKey) { + setPaneRatio(paneRatio - step); + } else if (event.key === increaseKey) { + setPaneRatio(paneRatio + step); + } else if (event.key === "Home") { + setPaneRatio(paneRatioBounds().lower); + } else if (event.key === "End") { + setPaneRatio(paneRatioBounds().upper); + } else { + return; + } + + event.preventDefault(); +}); + +function updateSplitterOrientation() { + paneSplitter.setAttribute( + "aria-orientation", + isVerticalLayout() ? "horizontal" : "vertical", + ); + setPaneRatio(paneRatio); +} + +verticalLayout.addEventListener("change", updateSplitterOrientation); +window.addEventListener("resize", () => setPaneRatio(paneRatio)); +updateSplitterOrientation(); + await init(); diff --git a/styles.css b/styles.css index 1b41470..8478252 100644 --- a/styles.css +++ b/styles.css @@ -211,10 +211,16 @@ h1 { } .explorer { + --source-pane-share: 1fr; + --detail-pane-share: 1fr; + height: max(640px, calc(100svh - 224px)); max-height: 920px; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: + minmax(0, var(--source-pane-share)) + 9px + minmax(0, var(--detail-pane-share)); border: 1px solid var(--line-strong); border-radius: 14px; overflow: hidden; @@ -231,7 +237,47 @@ h1 { } .source-pane { - border-right: 1px solid var(--line-strong); + border-right: 0; +} + +.pane-splitter { + min-width: 0; + position: relative; + z-index: 3; + border: 0; + padding: 0; + cursor: col-resize; + touch-action: none; + background: #f4f6f8; +} + +.pane-splitter::before { + width: 3px; + height: 34px; + position: absolute; + top: 50%; + left: 50%; + border-radius: 999px; + background: var(--line-strong); + content: ""; + transform: translate(-50%, -50%); + transition: background 120ms ease, box-shadow 120ms ease; +} + +.pane-splitter:hover::before, +.pane-splitter:focus-visible::before, +.pane-splitter.is-dragging::before { + background: var(--blue); + box-shadow: 0 0 0 3px rgb(36 107 253 / 14%); +} + +.pane-splitter:focus-visible { + outline-offset: -2px; +} + +body.is-resizing-panes { + cursor: col-resize; + user-select: none; } .pane-header, @@ -728,19 +774,36 @@ footer { } .explorer { - height: auto; + height: max(1180px, calc(100svh - 120px)); max-height: none; grid-template-columns: 1fr; + grid-template-rows: + minmax(0, var(--source-pane-share)) + 9px + minmax(0, var(--detail-pane-share)); } .source-pane { - height: 560px; + height: auto; border-right: 0; - border-bottom: 1px solid var(--line-strong); + border-bottom: 0; } .detail-pane { - min-height: 620px; + min-height: 0; + } + + .pane-splitter { + cursor: row-resize; + } + + .pane-splitter::before { + width: 34px; + height: 3px; + } + + body.is-resizing-panes { + cursor: row-resize; } }