setWidthOpen(false)} />
+
+ {WIDTH_OPTIONS.map((w) => (
+
+ ))}
+
+ >
+ )}
+
{exportOpen && (
<>
@@ -114,6 +144,7 @@ export function EditorView() {
+
{coverActive ? (
@@ -141,6 +172,7 @@ export function EditorView() {
>
)}
+ {dock && }
{dock && }
diff --git a/src/components/ResizeHandle.tsx b/src/components/ResizeHandle.tsx
new file mode 100644
index 0000000..14e4551
--- /dev/null
+++ b/src/components/ResizeHandle.tsx
@@ -0,0 +1,36 @@
+import type { PointerEvent } from "react";
+import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
+
+export function ResizeHandle({ pane }: { pane: Pane }) {
+ const onPointerDown = (e: PointerEvent
) => {
+ e.preventDefault();
+ const handle = e.currentTarget;
+ const startX = e.clientX;
+ const startWidth = currentPaneWidth(pane);
+ handle.setPointerCapture(e.pointerId);
+ document.body.classList.add("resizing");
+
+ const onMove = (ev: globalThis.PointerEvent) => {
+ const delta = ev.clientX - startX;
+ applyPaneWidth(pane, pane === "sidebar" ? startWidth + delta : startWidth - delta);
+ };
+ const onUp = () => {
+ document.body.classList.remove("resizing");
+ handle.removeEventListener("pointermove", onMove);
+ handle.removeEventListener("pointerup", onUp);
+ };
+ handle.addEventListener("pointermove", onMove);
+ handle.addEventListener("pointerup", onUp);
+ };
+
+ return (
+ resetPaneWidth(pane)}
+ title="Drag to resize ยท double-click to reset"
+ />
+ );
+}
diff --git a/src/panes.ts b/src/panes.ts
new file mode 100644
index 0000000..583728e
--- /dev/null
+++ b/src/panes.ts
@@ -0,0 +1,47 @@
+export type Pane = "sidebar" | "dock";
+
+const VAR: Record
= {
+ sidebar: "--pane-sidebar",
+ dock: "--pane-dock",
+};
+
+const KEY: Record = {
+ sidebar: "margin-pane-sidebar",
+ dock: "margin-pane-dock",
+};
+
+const DEFAULT: Record = {
+ sidebar: 248,
+ dock: 384,
+};
+
+const MIN: Record = {
+ sidebar: 200,
+ dock: 280,
+};
+
+const MAX: Record = {
+ sidebar: 460,
+ dock: 720,
+};
+
+function clamp(pane: Pane, px: number): number {
+ return Math.round(Math.min(MAX[pane], Math.max(MIN[pane], px)));
+}
+
+export function currentPaneWidth(pane: Pane): number {
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(VAR[pane]);
+ const n = parseInt(raw, 10);
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT[pane];
+}
+
+export function applyPaneWidth(pane: Pane, px: number): void {
+ const w = clamp(pane, px);
+ document.documentElement.style.setProperty(VAR[pane], `${w}px`);
+ localStorage.setItem(KEY[pane], String(w));
+}
+
+export function resetPaneWidth(pane: Pane): void {
+ document.documentElement.style.setProperty(VAR[pane], `${DEFAULT[pane]}px`);
+ localStorage.removeItem(KEY[pane]);
+}
diff --git a/src/store/useWidth.ts b/src/store/useWidth.ts
new file mode 100644
index 0000000..696fdeb
--- /dev/null
+++ b/src/store/useWidth.ts
@@ -0,0 +1,15 @@
+import { create } from "zustand";
+import { applyWidth, initialWidth, type Width } from "../width";
+
+interface WidthState {
+ width: Width;
+ setWidth: (width: Width) => void;
+}
+
+export const useWidth = create((set) => ({
+ width: initialWidth(),
+ setWidth: (width) => {
+ applyWidth(width);
+ set({ width });
+ },
+}));
diff --git a/src/styles/app.css b/src/styles/app.css
index 9c48163..5da035b 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -98,6 +98,46 @@ button {
min-height: 0;
}
+.pane-resizer {
+ flex: none;
+ width: 0;
+ position: relative;
+ z-index: 5;
+ cursor: col-resize;
+}
+
+.pane-resizer::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -4px;
+ width: 8px;
+}
+
+.pane-resizer::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -1px;
+ width: 2px;
+ background: var(--accent);
+ opacity: 0;
+ transition: opacity 120ms var(--ease);
+}
+
+.pane-resizer:hover::after,
+body.resizing .pane-resizer::after {
+ opacity: 0.55;
+}
+
+body.resizing {
+ cursor: col-resize;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
.sidebar {
width: var(--pane-sidebar);
flex: none;
@@ -1030,6 +1070,10 @@ button {
color: var(--ink);
}
+.menu button[data-on="true"] {
+ color: var(--accent);
+}
+
.export-overlay {
position: fixed;
inset: 0;
diff --git a/src/width.ts b/src/width.ts
new file mode 100644
index 0000000..af41959
--- /dev/null
+++ b/src/width.ts
@@ -0,0 +1,28 @@
+export type Width = "narrow" | "medium" | "wide" | "full";
+
+const KEY = "margin-width";
+
+const MEASURE: Record = {
+ narrow: "38em",
+ medium: "46em",
+ wide: "62em",
+ full: "80em",
+};
+
+export const WIDTH_OPTIONS: { id: Width; label: string }[] = [
+ { id: "narrow", label: "Narrow" },
+ { id: "medium", label: "Medium" },
+ { id: "wide", label: "Wide" },
+ { id: "full", label: "Full" },
+];
+
+export function initialWidth(): Width {
+ const saved = localStorage.getItem(KEY);
+ if (saved && saved in MEASURE) return saved as Width;
+ return "medium";
+}
+
+export function applyWidth(width: Width) {
+ document.documentElement.style.setProperty("--measure", MEASURE[width]);
+ localStorage.setItem(KEY, width);
+}