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",