Theseus 0.3.94: home — free-drag left area, card sub-descriptions back

The left column goes back to free positioning: each icon stores its own
x,y and snaps to a 20px grid; a faint grid shows underneath while dragging
so placement feels OS-desktop. Live-move via pointer events (clicks still
navigate thanks to a 4px move threshold). Dropping a left icon over the
right card grid re-parents it as a card; dropping a card onto the left
area turns it into a free-positioned icon.

Silent Mode cards get their one-line description back under the title.
Add/edit modal gains a Description field. Defaults seed sub-text on every
Silent Mode card and x,y on every Your-links icon.
This commit is contained in:
Local Dev 2026-10-21 00:27:21 +03:00
parent a3b20a2446
commit 2821453278
2 changed files with 174 additions and 58 deletions

230
home.html
View file

@ -35,14 +35,23 @@
gap: 36px; max-width: 1400px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
@media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
/* LEFT AREA — 3-column grid of slots, with the "add" button flowing in
the grid alongside them. Icons fill left-to-right, top-to-bottom; the
container is wide enough that a few icons still read as a tidy row. */
/* LEFT AREA — free-positioning canvas. Each icon stores its own x,y and
snaps to a 20px grid. A faint grid shows underneath while dragging to
make placement feel OS-desktop-like. */
.left-column { display: flex; flex-direction: column; gap: 14px; }
.slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; }
@media (max-width: 900px) { .slots-list { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); } }
.slots-list { position: relative; width: 100%; min-height: 480px; padding: 8px 4px; border-radius: 10px; }
.slots-list::before {
content: ""; position: absolute; inset: 0; opacity: 0;
background-image: linear-gradient(to right, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.04) 1px, transparent 1px);
background-size: 20px 20px; pointer-events: none; transition: opacity .15s;
border-radius: 10px;
}
body.dragging-any .slots-list::before { opacity: 1; }
.slots-list.drop-target, .cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
.slots-list .slot { position: absolute; }
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
.slot { display: flex; flex-direction: column; align-items: center;
cursor: pointer; user-select: none; text-decoration: none; color: inherit;
transition: transform .12s ease; }
.slot:hover { transform: translateY(-2px); }
@ -61,23 +70,16 @@
.slot.size-medium .tile { width: 72px; height: 72px; font-size: 28px; border-radius: 14px; }
.slot.size-large .tile { width: 88px; height: 88px; font-size: 34px; border-radius: 18px; }
.slot.size-small .label { font-size: 10.5px; max-width: 72px; }
.slot.dragging { opacity: .6; }
.slot.drag-before::before, .slot.drag-after::after {
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
}
.slot.drag-before::before { left: -8px; }
.slot.drag-after::after { right: -8px; }
.slot.dragging { opacity: .85; cursor: grabbing; z-index: 2; }
/* Slot-add sits in the grid alongside the slot icons so it's always on
the next free cell, not pinned under the whole column. */
.slot-add { display: flex; align-items: center; justify-content: center;
min-height: 72px; border-radius: 12px; border: 2px dashed var(--line2);
background: transparent; color: var(--mut); font-size: 22px; cursor: pointer;
transition: color .12s, border-color .12s, background .12s;
margin-top: 15px; /* visually below the label of the icon above */ }
.slots-list .slot-add { margin-top: 0; } /* in-grid, no extra top gap */
.slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
/* Slot-add floats at the bottom-left of the free-positioning area so the
user always has a way to add without hunting for empty space. */
.slot-add { position: absolute; left: 10px; bottom: 10px;
display: flex; align-items: center; justify-content: center;
width: 60px; height: 60px; border-radius: 14px; border: 2px dashed var(--line2);
background: rgba(20,26,36,.6); 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 / .08); }
/* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search
lives in the hero above. */
@ -86,8 +88,8 @@
@media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } }
.card { display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 20px 14px; min-height: 128px;
.card { display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
gap: 8px; padding: 18px 14px 16px; min-height: 148px;
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
text-decoration: none; color: inherit; cursor: pointer; position: relative;
transition: transform .12s, background .12s, border-color .12s; }
@ -102,6 +104,10 @@
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.card .card-title .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
.card .card-sub { font-size: 12px; color: var(--mut); text-align: center; line-height: 1.35;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; max-width: 100%; }
.card:hover .card-sub { color: var(--ink); }
.card.dragging { opacity: .6; }
.card.drag-before::before, .card.drag-after::after {
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
@ -231,6 +237,8 @@
<h2 id="modalTitle">Add link</h2>
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
<label>Description (shown on cards)</label>
<input id="mSub" placeholder="one short line — optional">
<div class="row">
<div>
<label>Column</label>
@ -311,26 +319,28 @@
let state = null;
function defaultState() {
return {
version: 5,
version: 6,
iconSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
sm: [
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus" },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode" },
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius" },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus" },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus" },
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes" },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios" },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne" },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis" },
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's home." },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure for a decentralized web." },
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },
],
// Your-links icons are free-positioned (per-item x,y, 20px snap grid).
// Defaults seed a tidy row across the top of the left area; drag anywhere.
q: [
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch" },
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin" },
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos" },
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot" },
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch", x: 10, y: 10 },
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin", x: 110, y: 10 },
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos", x: 210, y: 10 },
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot", x: 10, y: 120 },
],
},
};
@ -387,6 +397,8 @@
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
function render() {
// LEFT — free-positioned icons. Each stores its own x,y; legacy items
// without x,y fall back to a tidy auto-layout at the top of the area.
const q = state.sections.q || [];
const list = $("slotsList");
list.innerHTML = "";
@ -394,6 +406,9 @@
const el = document.createElement("a");
el.className = `slot size-${state.iconSize}`;
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i;
const autoX = 10 + (i % 3) * 100, autoY = 10 + Math.floor(i / 3) * 110;
el.style.left = (ic.x != null ? ic.x : autoX) + "px";
el.style.top = (ic.y != null ? ic.y : autoY) + "px";
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
@ -402,7 +417,7 @@
el.style.setProperty("--iconFg", "#e7eaf1");
}
el.innerHTML = `<div class="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
wireItem(el, "q", i);
wireSlot(el, i);
list.appendChild(el);
});
const slotAdd = document.createElement("button");
@ -411,6 +426,7 @@
slotAdd.oncontextmenu = (e) => e.preventDefault();
list.appendChild(slotAdd);
// RIGHT — Silent Mode cards with sub-description under the title.
const sm = state.sections.sm || [];
const grid = $("cardsGrid");
grid.innerHTML = "";
@ -422,8 +438,9 @@
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
}
el.innerHTML = `<div class="card-ico">${iconArt(ic)}</div><div class="card-title">${styledTitle(ic.title)}</div>`;
wireItem(el, "sm", i);
const sub = ic.sub ? `<div class="card-sub">${esc(ic.sub)}</div>` : "";
el.innerHTML = `<div class="card-ico">${iconArt(ic)}</div><div class="card-title">${styledTitle(ic.title)}</div>${sub}`;
wireCard(el, i);
grid.appendChild(el);
});
const add = document.createElement("button");
@ -435,25 +452,107 @@
$("ctxIconSizeVal").textContent = state.iconSize;
}
// Drag-reorder: left column is a vertical stack, right column is a 3-wide
// grid. We use the HTML5 drag-and-drop API because it's simple and the
// user explicitly asked for drag-and-drop. A tiny move-threshold would
// require pointer events; keeping HTML5 DnD means plain clicks navigate.
function wireItem(el, sec, i) {
el.draggable = true;
// LEFT slots use pointer events for free-drag: live movement with snap to
// a 20px grid, final x,y committed on pointerup. A short move threshold
// keeps plain clicks working for navigation. Dropping over the right
// card grid re-parents the item (removes x,y, appends to sm).
const SNAP = 20, DRAG_THRESHOLD = 4;
let drag = null;
function wireSlot(el, i) {
el.addEventListener("click", (e) => {
if (drag && drag.moved) { e.preventDefault(); return; }
e.preventDefault();
const url = state.sections[sec][i].url;
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, sec, i);
openCtxItem(e.clientX, e.clientY, "q", i);
});
el.addEventListener("pointerdown", (e) => startSlotDrag(e, i, el));
}
function startSlotDrag(e, i, el) {
if (e.button !== 0) return;
if (e.target.closest(".ctx-menu")) return;
const rect = el.getBoundingClientRect();
drag = {
kind: "slot", i, el,
grabX: e.clientX - rect.left, grabY: e.clientY - rect.top,
startX: e.clientX, startY: e.clientY, moved: false,
origLeft: el.style.left, origTop: el.style.top,
};
el.setPointerCapture(e.pointerId);
hideAllCtx();
}
window.addEventListener("pointermove", (e) => {
if (!drag || drag.kind !== "slot") return;
if (!drag.moved) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < DRAG_THRESHOLD) return;
drag.moved = true;
drag.el.classList.add("dragging");
document.body.classList.add("dragging-any");
}
const slotsList = $("slotsList");
const target = document.elementFromPoint(e.clientX, e.clientY);
const overSlots = target && target.closest("#slotsList");
const overCards = target && target.closest("#cardsGrid");
document.querySelectorAll(".drop-target").forEach((g) => g.classList.remove("drop-target"));
if (overCards) overCards.classList.add("drop-target");
if (overSlots || !overCards) {
// Live-move within the free area.
const r = slotsList.getBoundingClientRect();
const w = drag.el.offsetWidth, h = drag.el.offsetHeight;
const nx = Math.max(0, Math.min(r.width - w, e.clientX - r.left - drag.grabX));
const ny = Math.max(0, Math.min(r.height - h, e.clientY - r.top - drag.grabY));
drag.el.style.left = Math.round(nx / SNAP) * SNAP + "px";
drag.el.style.top = Math.round(ny / SNAP) * SNAP + "px";
}
});
window.addEventListener("pointerup", (e) => {
if (!drag || drag.kind !== "slot") return;
const el = drag.el;
el.classList.remove("dragging");
document.body.classList.remove("dragging-any");
document.querySelectorAll(".drop-target").forEach((g) => g.classList.remove("drop-target"));
if (drag.moved) {
const target = document.elementFromPoint(e.clientX, e.clientY);
const overCards = target && target.closest("#cardsGrid");
if (overCards) {
// Re-parent into the right-side grid (drop x,y — cards flow).
const moved = state.sections.q[drag.i];
delete moved.x; delete moved.y;
state.sections.q.splice(drag.i, 1);
state.sections.sm.push(moved);
persist(); render();
} else {
// Commit the final x,y to state and persist.
const moved = state.sections.q[drag.i];
moved.x = parseInt(el.style.left, 10);
moved.y = parseInt(el.style.top, 10);
persist();
}
}
drag = null;
});
// RIGHT cards keep HTML5 DnD for grid reorder with before/after indicators.
function wireCard(el, i) {
el.draggable = true;
el.addEventListener("click", (e) => {
e.preventDefault();
const url = state.sections.sm[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, "sm", i);
});
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec, i }));
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "sm", i }));
el.classList.add("dragging");
hideAllCtx();
});
@ -465,7 +564,6 @@
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect();
// Both sections are 2D grids now — horizontal mid-split picks insert side.
const before = e.clientX < r.left + r.width / 2;
el.classList.toggle("drag-before", before);
el.classList.toggle("drag-after", !before);
@ -480,20 +578,36 @@
const r = el.getBoundingClientRect();
const side = e.clientX < r.left + r.width / 2 ? -1 : 1;
let targetIdx = i + (side === 1 ? 1 : 0);
moveItem(payload.sec, payload.i, sec, targetIdx);
moveItem(payload.sec, payload.i, "sm", targetIdx);
});
}
// The slots-list and cards-grid themselves accept drops at the end — this
// handles cross-column moves when the pointer lands on empty space.
// Container-level drops. Slot→card re-parenting is handled by the slot
// pointer flow; this handles card→slot (seeds x,y) and card reorder onto
// empty cards-grid space.
function wireContainer(container, sec) {
container.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; });
container.addEventListener("drop", (e) => {
if (e.target !== container) return;
e.preventDefault();
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
moveItem(payload.sec, payload.i, sec, state.sections[sec].length);
if (sec === "q") {
// Card → slot. Pull it out of sm, seed x,y under the drop point.
if (payload.sec !== "sm") return;
const r = container.getBoundingClientRect();
const moved = state.sections.sm[payload.i];
if (!moved) return;
state.sections.sm.splice(payload.i, 1);
const nx = Math.round(Math.max(0, e.clientX - r.left - 36) / SNAP) * SNAP;
const ny = Math.round(Math.max(0, e.clientY - r.top - 36) / SNAP) * SNAP;
moved.x = nx; moved.y = ny;
state.sections.q.push(moved);
persist(); render();
return;
}
// sec === "sm" — card reorder onto empty space (append).
if (e.target !== container) return;
moveItem(payload.sec, payload.i, "sm", state.sections.sm.length);
});
}
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
@ -648,10 +762,11 @@
editKey = { sec, i };
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add link" : "Edit link";
const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "" }
const c = isNew ? { title: "", url: "", sub: "", color: "#d6ff3d", letter: "" }
: state.sections[sec][i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSub").value = c.sub || "";
$("mSection").value = sec;
$("mColor").value = c.color || "#d6ff3d";
$("mLetter").value = c.letter || "";
@ -668,6 +783,7 @@
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const next = {
title, url,
sub: $("mSub").value.trim() || null,
color: $("mColor").value,
letter: $("mLetter").value.trim() || null,
};

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.93",
"version": "0.3.94",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",