From 964536b5c85e05339a03b694d65e1a776f4def63 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 22 Oct 2026 21:31:45 +0300 Subject: [PATCH] Theseus 0.3.105: fixed-slot left grid + Ariadne dark-bg icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left column slot grid grows to a visible 3-column fixed cell layout. Each link can be pinned to a specific slot by dropping onto an empty cell; unpinned links flow top-to-bottom through the remaining cells. Dropping an item onto another slot swaps the two positions, so the grid never collapses underneath the user. The Ariadne.X home card now uses the brand kit's "ivory on dark" mark (variant-ivory-glow) instead of the terracotta pottery sketch. The ivory bust reads cleanly against the navy card surface — the pottery version was designed for cream backgrounds and got lost against the dark card. --- home-brand/ariadne-mark.svg | 6 ++ home.html | 179 ++++++++++++++++++++++++++++++++---- package.json | 2 +- 3 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 home-brand/ariadne-mark.svg diff --git a/home-brand/ariadne-mark.svg b/home-brand/ariadne-mark.svg new file mode 100644 index 00000000..40e74d09 --- /dev/null +++ b/home-brand/ariadne-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/home.html b/home.html index 94a6aa75..c08a307f 100644 --- a/home.html +++ b/home.html @@ -82,6 +82,14 @@ background: transparent; color: var(--mut); font-size: 20px; cursor: pointer; transition: color .12s, border-color .12s, background .12s; } .slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); } + /* Empty slots in the grid: drop targets for pinning any link to a + specific slot. Order matches the grid's cell index. Subtly visible + so users see where they can drop; brighter on hover of the strip. */ + .slot-empty { display: block; min-height: 72px; border-radius: 12px; + border: 2px dashed rgb(from var(--line2) r g b / .55); background: transparent; + transition: border-color .12s, background .12s; } + .slots-list:hover .slot-empty { border-color: var(--line2); } + .slot-empty.drop-hot { border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); } /* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search lives in the hero above. */ @@ -306,7 +314,7 @@ prometheus: ``, hermes: { src: "home-brand/hermes-mark.svg" }, helios: ``, - ariadne: { src: "home-brand/ariadne-pottery.svg" }, + ariadne: { src: "home-brand/ariadne-mark.svg" }, pithos: ``, aegis: ``, bergamot: ``, @@ -400,25 +408,38 @@ function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); } function render() { - // LEFT — fixed-cell grid. Icons flow left-to-right, top-to-bottom; the - // "+" cell always sits at the end so there's a visible way to add. + // LEFT — fixed-cell grid of up to 9+ slots (3 wide × as many rows as + // needed). Each item in q[] may carry a `slot: number` to pin it to a + // specific cell; items without a slot flow top-to-bottom into the + // first unoccupied cells. Empty cells are drop targets, visible on + // hover of the grid. The "+" cell sits at the end so adding is always + // one click away. const q = state.sections.q || []; + const slotCells = layoutSlots(q); const list = $("slotsList"); list.innerHTML = ""; - q.forEach((ic, i) => { - const el = document.createElement("a"); - el.className = `slot size-${state.iconSize}`; - el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; - if (!iconUsesBrand(ic)) { - el.style.setProperty("--iconBg", ic.color || "#d6ff3d"); - el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff"); + slotCells.forEach((entry, cellIdx) => { + if (entry) { + const ic = entry.item, i = entry.i; + const el = document.createElement("a"); + el.className = `slot size-${state.iconSize}`; + el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; el.dataset.cell = cellIdx; + if (!iconUsesBrand(ic)) { + el.style.setProperty("--iconBg", ic.color || "#d6ff3d"); + el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff"); + } else { + el.style.setProperty("--iconBg", "transparent"); + el.style.setProperty("--iconFg", "#e7eaf1"); + } + el.innerHTML = `
${iconArt(ic)}
${esc(ic.title || ic.url)}
`; + wireSlot(el, i); + list.appendChild(el); } else { - el.style.setProperty("--iconBg", "transparent"); - el.style.setProperty("--iconFg", "#e7eaf1"); + const empty = document.createElement("div"); + empty.className = "slot-empty"; empty.dataset.cell = cellIdx; + wireEmptySlot(empty, cellIdx); + list.appendChild(empty); } - el.innerHTML = `
${iconArt(ic)}
${esc(ic.title || ic.url)}
`; - wireSlot(el, i); - list.appendChild(el); }); const slotAdd = document.createElement("button"); slotAdd.className = "slot-add"; slotAdd.textContent = "+"; slotAdd.title = "Add link"; @@ -452,13 +473,139 @@ $("ctxIconSizeVal").textContent = state.iconSize; } + // Lay out the q[] items into a fixed-slot grid: pinned items (with a + // `slot: number`) go into that cell; unpinned items flow into the first + // free cell, top-to-bottom. The grid always extends to at least one + // trailing empty row for drop space. + function layoutSlots(items, minSlots = 9) { + const pinned = []; const unpinned = []; + items.forEach((ic, i) => { + const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null; + if (s !== null) pinned.push({ item: ic, i, slot: s }); + else unpinned.push({ item: ic, i }); + }); + const maxPinned = pinned.reduce((m, p) => Math.max(m, p.slot), -1); + const used = pinned.length + unpinned.length; + let need = Math.max(minSlots, maxPinned + 1, used); + need = Math.ceil(need / 3) * 3; + need = Math.max(need, used + 1); // keep one trailing empty cell visible + need = Math.ceil(need / 3) * 3; + const cells = new Array(need).fill(null); + for (const p of pinned) { + if (p.slot < cells.length && !cells[p.slot]) cells[p.slot] = { item: p.item, i: p.i }; + else unpinned.push({ item: p.item, i: p.i }); // collision: demote to unpinned + } + let u = 0; + for (let i = 0; i < cells.length && u < unpinned.length; i++) { + if (!cells[i]) { cells[i] = unpinned[u++]; } + } + return cells; + } + // Both sections are 2D grids: slots are fixed cells on the left (3 wide), // cards are fixed cells on the right (3 wide). Drag reorders inside a // section with a before/after indicator on the hover target; drop onto // the other container's empty space re-parents (appends). - function wireSlot(el, i) { wireGridItem(el, "q", i); } + // Left-grid slots: drop onto a filled slot swaps the two slot assignments + // (so the user can rearrange visually without having to think about array + // order). Drop onto an empty slot is handled by wireEmptySlot. + function wireSlot(el, i) { + el.draggable = true; + el.addEventListener("click", (e) => { + e.preventDefault(); + const url = state.sections.q[i].url; + if (window.home && window.home.navigate) window.home.navigate(url); + else window.location.href = url; + }); + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); e.stopPropagation(); + openCtxItem(e.clientX, e.clientY, "q", i); + }); + el.addEventListener("dragstart", (e) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "q", i })); + el.classList.add("dragging"); + hideAllCtx(); + }); + el.addEventListener("dragend", () => { + el.classList.remove("dragging"); + document.querySelectorAll(".drop-hot,.drag-before,.drag-after") + .forEach((n) => n.classList.remove("drop-hot", "drag-before", "drag-after")); + }); + el.addEventListener("dragover", (e) => { + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + el.classList.add("drag-before"); + }); + el.addEventListener("dragleave", () => el.classList.remove("drag-before")); + el.addEventListener("drop", (e) => { + e.preventDefault(); el.classList.remove("drag-before"); + const payload = tryParse(e.dataTransfer.getData("application/x-slot")); + if (!payload) return; + const targetCell = Number(el.dataset.cell); + if (payload.sec !== "q") { pinItemToSlot(payload.sec, payload.i, targetCell); return; } + if (payload.i === i) return; + swapSlots(payload.i, i, targetCell); + }); + } function wireCard(el, i) { wireGridItem(el, "sm", i); } + // Swap the pinned-slot positions of two q items. The dropped item takes + // over the target's cell; the target moves into whatever cell the dropped + // item was in. Unpinned items get pinned as part of the swap. + function swapSlots(fromI, toI, targetCell) { + const q = state.sections.q; + const src = q[fromI], dst = q[toI]; + if (!src || !dst) return; + const prevSrcSlot = Number.isInteger(src.slot) ? src.slot : null; + src.slot = targetCell; + // The target item moves into the source's old slot; if source was + // unpinned, we need to give the target a slot too (its current flowed + // position) so it doesn't jump when the source leaves. + if (prevSrcSlot !== null) dst.slot = prevSrcSlot; + else { + // Find where dst currently sits in the laid-out grid, pre-swap. + const grid = layoutSlots(q); + const dstCell = grid.findIndex((e) => e && e.i === toI); + dst.slot = dstCell >= 0 ? dstCell : targetCell; + // After the swap, dst takes the old source cell. Re-compute. + const srcCell = grid.findIndex((e) => e && e.i === fromI); + if (srcCell >= 0) dst.slot = srcCell; + } + persist(); render(); + } + + // Empty cell in the left grid: accepts a dropped slot (from q or sm) and + // pins the item to this cell's index. Dropping an unpinned item here + // promotes it to pinned; dropping a pinned item just moves its pin. + function wireEmptySlot(el, cellIdx) { + el.addEventListener("dragover", (e) => { + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + el.classList.add("drop-hot"); + }); + el.addEventListener("dragleave", () => el.classList.remove("drop-hot")); + el.addEventListener("drop", (e) => { + e.preventDefault(); el.classList.remove("drop-hot"); + const payload = tryParse(e.dataTransfer.getData("application/x-slot")); + if (!payload) return; + pinItemToSlot(payload.sec, payload.i, cellIdx); + }); + } + + // Place the dragged item at `cellIdx` in the q grid, pinning it. + // Cross-section (sm → q) moves the item first, then pins. Same-cell + // self-drop is a no-op. + function pinItemToSlot(fromSec, fromI, cellIdx) { + if (fromSec !== "q") { + const [m] = state.sections[fromSec].splice(fromI, 1); + m.slot = cellIdx; + state.sections.q.push(m); + } else { + const it = state.sections.q[fromI]; if (!it) return; + it.slot = cellIdx; + } + persist(); render(); + } + function wireGridItem(el, sec, i) { el.draggable = true; el.addEventListener("click", (e) => { diff --git a/package.json b/package.json index efaedda4..0af828b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.104", + "version": "0.3.105", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode",