Theseus 0.3.105: fixed-slot left grid + Ariadne dark-bg icon

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.
This commit is contained in:
Local Dev 2026-10-22 21:31:45 +03:00
parent 82c9408cdf
commit 964536b5c8
3 changed files with 170 additions and 17 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

179
home.html
View file

@ -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: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 8 C 26 18, 20 22, 20 32 C 20 42, 26 50, 32 50 C 38 50, 44 42, 44 32 C 44 24, 40 22, 36 18 C 36 24, 34 26, 32 28 C 30 24, 34 16, 32 8 Z" fill="#ffc75f"/><path d="M32 20 C 28 26, 26 30, 26 36 C 26 42, 29 46, 32 46 C 35 46, 38 42, 38 36 C 38 32, 36 30, 34 26 C 34 30, 33 32, 32 32 C 30 28, 33 24, 32 20 Z" fill="#f6768a"/></svg>`,
hermes: { src: "home-brand/hermes-mark.svg" },
helios: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="12" fill="#ffc75f"/><g stroke="#ffc75f" stroke-width="3" stroke-linecap="round"><line x1="32" y1="8" x2="32" y2="14"/><line x1="32" y1="50" x2="32" y2="56"/><line x1="8" y1="32" x2="14" y2="32"/><line x1="50" y1="32" x2="56" y2="32"/><line x1="15" y1="15" x2="19" y2="19"/><line x1="45" y1="45" x2="49" y2="49"/><line x1="15" y1="49" x2="19" y2="45"/><line x1="45" y1="19" x2="49" y2="15"/></g></svg>`,
ariadne: { src: "home-brand/ariadne-pottery.svg" },
ariadne: { src: "home-brand/ariadne-mark.svg" },
pithos: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M22 18h20v4c0 2-2 3-2 5 8 3 14 10 14 20 0 10-9 17-22 17S10 57 10 47c0-10 6-17 14-20 0-2-2-3-2-5z" fill="#3ddc97"/><path d="M14 36h36M14 44h36" stroke="#0b1f18" stroke-width="2.5" stroke-linecap="round" opacity=".55"/></svg>`,
aegis: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="#0a0a0d" stroke="#D6FF3D" stroke-width="1.6" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.5" fill="none" stroke="#D6FF3D" stroke-width="1.4"/><circle cx="16" cy="16" r="1.6" fill="#D6FF3D"/></svg>`,
bergamot: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 50 C 14 28, 30 14, 54 14 C 54 36, 40 50, 18 50 C 20 42, 30 32, 44 28" fill="#9fd98f" stroke="#4fa66b" stroke-width="2" stroke-linejoin="round"/></svg>`,
@ -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 = `<div class="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
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 = `<div class="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
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) => {

View file

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