diff --git a/ventures/havre/index.html b/ventures/havre/index.html index 1d4b656..4cfbee9 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -24,6 +24,7 @@ + diff --git a/ventures/havre/split.js b/ventures/havre/split.js new file mode 100644 index 0000000..7762609 --- /dev/null +++ b/ventures/havre/split.js @@ -0,0 +1,54 @@ +// split.js: on a wide screen, the line between the list (chats / characters / harbor) and the chat can be dragged, or +// moved with the arrow keys once focused. Double-click (or Home) puts it back. The width is kept in this browser. +// Below 1040px the two take turns on screen, so there is nothing to drag and the divider hides (style.css). +(() => { + const KEY = "havre.split", MIN = 320, CHAT_MIN = 380; + const get = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch { return null; } }; + const put = (v) => { try { v == null ? localStorage.removeItem(KEY) : localStorage.setItem(KEY, JSON.stringify(v)); } catch { /* not kept */ } }; + function start() { + const studio = document.getElementById("studio"), chars = document.getElementById("chars"), chat = document.getElementById("chat"); + if (!studio || !chars || !chat) return; + const bar = document.createElement("div"); + bar.className = "splitter"; + bar.tabIndex = 0; + bar.setAttribute("role", "separator"); + bar.setAttribute("aria-orientation", "vertical"); + bar.setAttribute("aria-controls", "chars"); + bar.setAttribute("aria-label", "Resize the list and the chat"); + bar.title = "drag to resize ยท double-click to reset"; + chat.before(bar); + const wide = () => window.matchMedia("(min-width: 1041px)").matches; + const clamp = (w) => Math.round(Math.max(MIN, Math.min(w, studio.clientWidth - CHAT_MIN - bar.offsetWidth))); + function set(w, keep = true) { + if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(null); return; } + w = clamp(w); + chars.style.flex = `0 0 ${w}px`; + bar.setAttribute("aria-valuenow", String(w)); + if (keep) put(w); + } + set(get(), false); + window.addEventListener("resize", () => set(get(), false)); + bar.addEventListener("pointerdown", (e) => { + if (!wide() || e.button !== 0) return; + e.preventDefault(); + bar.setPointerCapture(e.pointerId); + const left = chars.getBoundingClientRect().left; + document.body.classList.add("splitting"); + const move = (ev) => set(ev.clientX - left); + const up = () => { bar.removeEventListener("pointermove", move); bar.removeEventListener("pointerup", up); bar.removeEventListener("pointercancel", up); document.body.classList.remove("splitting"); }; + bar.addEventListener("pointermove", move); + bar.addEventListener("pointerup", up); + bar.addEventListener("pointercancel", up); + }); + bar.addEventListener("dblclick", () => set(null)); + bar.addEventListener("keydown", (e) => { + const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20; + if (e.key === "ArrowLeft") set(now - step); + else if (e.key === "ArrowRight") set(now + step); + else if (e.key === "Home") set(null); + else return; + e.preventDefault(); + }); + } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/ventures/havre/style.css b/ventures/havre/style.css index e9f033d..b39c110 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -144,6 +144,10 @@ input:disabled, textarea:disabled, select:disabled { background: var(--well); co /* ---------- the studio: characters | chat ---------- */ .studio { display: flex; flex-wrap: wrap; align-items: flex-start; border-bottom: 2px solid var(--ink); } .chars { flex: 1.15 1 560px; min-width: 0; align-self: stretch; border-right: 1px solid var(--ink); } +/* the line between the list and the chat, to drag (split.js) */ +.splitter { flex: 0 0 7px; align-self: stretch; margin-left: -4px; margin-right: -3px; position: relative; z-index: 2; cursor: col-resize; touch-action: none; } +.splitter:hover, .splitter:focus-visible, .splitting .splitter { background: var(--hi); outline: none; } +body.splitting { cursor: col-resize; user-select: none; } .chat { flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column; position: sticky; top: 0; max-height: 100vh; @@ -504,6 +508,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); /* ---------- narrow screens ---------- */ @media (max-width: 1040px) { + .splitter { display: none; } .chars { border-right: 0; border-bottom: 1px solid var(--ink); } /* one at a time, like a phone's messages: the list, or the chat. In the chat the left column's tabs stay above it, without their panels, so CHATS / CHARACTERS / HARBOR are always one tap away */ diff --git a/ventures/havre/update.js b/ventures/havre/update.js index 5309d00..92ac252 100644 --- a/ventures/havre/update.js +++ b/ventures/havre/update.js @@ -4,7 +4,7 @@ // Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files. (() => { const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js", - "threads.js", "media.js", "backup.js", "theme.js"]; + "threads.js", "media.js", "backup.js", "theme.js", "split.js"]; let base = null, shown = false, busy = false; async function print() { const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));