Theseus 0.3.90: a desktop home page — two columns, brand icons, pick-and-move

The new-tab page is now a two-column desktop. The right column holds the
curated Silent Mode X-family (Theseus.X, SilentMode.X, Sirius.X,
Hephaestus.X, Prometheus.X, Hermes.X, Helios.X, Ariadne.X, Aegis.X) with
real brand marks pulled from each product's own favicon or add-on manifest.
The left column is yours — hello.bch, sia.coin, Pithos.sia, Bergamot.X by
default, plus whatever you add. Icons pick up and move within and across
columns in edit mode.

Edit mode also carries per-column default-size pickers (S / M / L / XL),
six background presets + a custom colour + an image upload, and + slots
in each column. The Ariadne.X mark uses the real face/thread illustration
in ancient-Greek-pottery colours (terracotta rings on cream). The Aegis.X
tile matches the one the add-on itself ships.

The on-disk format moves from a bare array of cards (v1) to a v3 object:
  { version:3, iconSize, quickSize, background, sections:{ sm, q } }
On first read, a v1 array lands as sections.q and the Silent Mode column
stays at the code default. The remote home-cards.json URL is still
accepted as either shape; migrateCards() normalises before use.
This commit is contained in:
Silent Mode 2026-10-12 01:41:17 +02:00
parent 5e7a8b14a8
commit 94ec382f20
4 changed files with 716 additions and 335 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

891
home.html
View file

@ -1,292 +1,603 @@
<!doctype html>
<html>
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Theseus</title>
<!-- Theseus compass mark as the tab icon of the home / new-tab page (inline, so it needs no network and no file path). Same mark as theseus.x/assets/favicon.svg. -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0e14'/%3E%3Cpath d='M32 6 L38 32 L26 32 Z' fill='%23e04e4e'/%3E%3Cpath d='M38 32 L32 58 L26 32 Z' fill='%23d6ff3d'/%3E%3Cpath d='M6 32 L32 27 L58 32 L32 37 Z' fill='%23d6ff3d' opacity='0.55'/%3E%3Ccircle cx='32' cy='32' r='3' fill='%230b0e14'/%3E%3C/svg%3E">
<style>
:root { color-scheme: light dark;
--bg1: #16202e; --bg2: #0b0e14;
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
color: var(--ink); display: flex; flex-direction: column; align-items: center; }
.hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; }
.mark { font-size: 64px; line-height: 1; }
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
h1 .g { color: var(--acid); }
.tag { color: var(--mut); margin: 0 0 1.6rem; }
form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
background: var(--card); color: var(--ink); font-size: 15px; outline: none; }
form input:focus { border-color: var(--blue); }
form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
/* Cards — larger, responsive. Grid autoscales to viewport width:
wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */
.cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; }
.cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; }
.cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); }
.cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut);
border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; }
.cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); }
.cards-hdr .tools button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
@media (min-width: 900px) { .grid { gap: 18px; } }
@media (min-width: 1200px) { .grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .cards-wrap { padding: 0 .8rem; } }
.card { display: block; text-decoration: none; color: inherit; background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 20px 20px 18px; transition: transform .15s, background .15s, border-color .15s;
position: relative; cursor: pointer; }
.card:hover { border-color: rgba(75,123,236,.4); background: var(--card-hi); transform: translateY(-2px); }
.card .n { font-weight: 600; color: var(--ink); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* TLD suffix (.x, .bch, ...) rendered in the Silent Mode acid so the brand
accent reads instantly across the grid. Subtle glow reinforces it. */
.card .n .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
.card .d { color: var(--mut); font-size: 13px; margin-top: 6px; line-height: 1.4; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.card:hover .d { color: var(--ink); }
.card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 8px; }
.badge { display: inline-block; font-size: 10.5px; padding: 2px 9px; border-radius: 999px; letter-spacing: .02em; }
/* Provenance-typed badges (which chain/storage backed the site). */
.b-on-chain { background: rgb(from var(--acid) r g b / .14); color: var(--acid); }
.b-sia { background: rgba(179,157,219,.14); color: var(--sia); }
.b-server { background: rgba(79,209,165,.14); color: var(--srv); }
/* Product-role badges — same acid family with per-role hue shifts.
Kept muted so the badge is legible but doesn't compete with the title. */
.b-infrastructure { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
.b-navigator { background: rgba(75,123,236,.14); color: #7ea3ff; }
.b-registrar { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
.b-code-host { background: rgba(179,157,219,.14); color: var(--sia); }
.b-app-store { background: rgba(79,209,165,.14); color: var(--srv); }
.b-search { background: rgba(255,199,95,.14); color: #ffc75f; }
.b-messaging { background: rgba(246,118,138,.14); color: #f6768a; }
.b-default { background: rgba(139,152,169,.14); color: var(--mut); }
/* Edit affordances — hidden until Edit mode toggled */
.card .edit-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; }
.card .edit-actions button { border: 1px solid var(--line2); background: rgba(0,0,0,.35); color: var(--mut);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 0; }
.card .edit-actions button:hover { color: var(--ink); background: rgba(0,0,0,.55); }
.card .edit-actions button.rm:hover { color: #f6768a; border-color: rgba(246,118,138,.5); }
body.editing .card .edit-actions { display: flex; }
body.editing .card { cursor: default; }
body.editing .card:hover { transform: none; }
.add-card { display: none; align-items: center; justify-content: center;
background: transparent; border: 2px dashed var(--line2); border-radius: 14px;
color: var(--mut); font-size: 14px; cursor: pointer; padding: 20px; min-height: 100px; }
.add-card:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
body.editing .add-card { display: flex; }
/* Modal for add/edit */
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.on { display: flex; }
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
.modal input:focus, .modal select:focus { border-color: var(--blue); }
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
background: transparent; color: var(--mut); }
.modal button:hover { background: var(--card-hi); color: var(--ink); }
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.modal button.primary:hover { background: #5a89f5; }
footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; }
footer b { color: #6b7688; }
/* Light theme */
@media (prefers-color-scheme: light) {
:root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
/* Darker acid for light backgrounds — ~5.5:1 on white. */
--acid: #0AC18E; }
h1 .g { color: var(--acid); }
.modal { background: #ffffff; }
}
</style></head>
<body>
<div class="hero">
<div class="mark">⛓️</div>
<h1>Theseus <span class="g">Navigator</span></h1>
<p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p>
<form id="searchForm">
<input id="q" name="q" placeholder="Search the web" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p>
</div>
<div class="cards-wrap">
<div class="cards-hdr">
<span class="title">Quick links</span>
<div class="tools">
<button id="editBtn" type="button">Edit</button>
<button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button>
</div>
</div>
<div class="grid" id="grid"></div>
</div>
<div class="modal-bg" id="modal">
<div class="modal">
<h2 id="modalTitle">Add card</h2>
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
<label>Subtitle (optional)</label><input id="mSub" placeholder="one-line description">
<label>Badge</label>
<select id="mBadge">
<option value="">— none —</option>
<option value="on-chain">on-chain</option>
<option value="Sia">Sia</option>
<option value="server">server</option>
<option value="default">(custom)</option>
</select>
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div>
</div>
<footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;");
let cards = []; let editIdx = -1;
// Search form: submit → navigate the tab via IPC (main.js applies the
// user's default search engine + regional hints).
$("searchForm").addEventListener("submit", (e) => {
e.preventDefault();
const q = $("q").value.trim(); if (!q) return;
if (window.home && window.home.navigate) window.home.navigate(q);
// Fallback if the preload didn't bind (shouldn't happen). Startpage
// over DuckDuckGo since that's Theseus's own default engine now.
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q);
});
// Populate placeholder with the user's current default engine name so
// "Search the web with <engine>" reflects reality. Silently no-ops if
// the preload or IPC isn't available (dev-open of home.html in a plain
// Chromium tab). Also refreshes on onEngines events would be nice but
// main only broadcasts `engines` to chrome — home only queries at load,
// which is fine because switching engines is a settings action that
// usually implies a reload of the tab shortly after.
if (window.home && window.home.getEngines) {
window.home.getEngines().then((r) => {
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name;
if (name) $("q").placeholder = "Search the web with " + name;
}).catch(() => {});
}
function badgeClass(b) {
// Normalize badge label into a CSS class (spaces + case → dashes / lower).
if (!b) return "b-default";
return "b-" + String(b).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
// Split a domain-style title so ".X" (or ".bch") can render in the acid brand
// color while the rest stays ink. Preserves capitals in the base label.
function styledTitle(t) {
const m = String(t).match(/^(.+?)(\.[A-Za-z]{1,10})$/);
if (!m) return esc(t);
return esc(m[1]) + '<span class="tld">' + esc(m[2]) + '</span>';
}
function render() {
const g = $("grid");
g.innerHTML = cards.map((c, i) => {
const badge = c.badge ? `<span class="badge ${badgeClass(c.badge)}">${esc(c.badge)}</span>` : "";
return `<div class="card" data-i="${i}">
<div class="edit-actions">
<button class="ed" title="Edit">✎</button>
<button class="rm" title="Remove">✕</button>
</div>
<div class="n">${styledTitle(c.title)}</div>
<div class="d">${esc(c.sub || c.url)}</div>
<div class="row">${badge}</div>
</div>`;
}).join("") + `<div class="add-card" id="addBtn">+ Add card</div>`;
// Wire clicks: normal-mode = navigate, edit-mode = per-action buttons.
g.querySelectorAll(".card").forEach((el) => {
const i = Number(el.dataset.i);
el.onclick = (e) => {
if (document.body.classList.contains("editing")) {
if (e.target.classList.contains("rm")) return removeCard(i);
if (e.target.classList.contains("ed")) return openModal(i);
return; // click on card body in edit mode = no-op
}
// normal mode: navigate
if (window.home && window.home.navigate) window.home.navigate(cards[i].url);
else window.location.href = cards[i].url;
};
});
$("addBtn").onclick = () => openModal(-1);
}
function removeCard(i) {
if (!confirm(`Remove "${cards[i].title}" from Quick links?`)) return;
cards.splice(i, 1); persist(); render();
}
function openModal(i) {
editIdx = i;
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add card" : "Edit card";
const c = isNew ? { title: "", url: "", sub: "", badge: "" } : cards[i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSub").value = c.sub || "";
$("mBadge").value = c.badge || "";
$("modal").classList.add("on");
setTimeout(() => $("mTitle").focus(), 20);
}
function closeModal() { $("modal").classList.remove("on"); }
$("mCancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
$("mSave").onclick = () => {
const title = $("mTitle").value.trim();
let url = $("mUrl").value.trim();
if (!title || !url) { alert("Title and URL are required."); return; }
// Normalize URL: allow bare hosts like "example.com" (add https://).
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const card = { title, url, sub: $("mSub").value.trim(), badge: $("mBadge").value };
if (editIdx < 0) cards.push(card); else cards[editIdx] = card;
persist(); render(); closeModal();
};
$("editBtn").onclick = () => {
const editing = document.body.classList.toggle("editing");
$("editBtn").classList.toggle("on", editing);
$("editBtn").textContent = editing ? "Done" : "Edit";
$("resetBtn").style.display = editing ? "" : "none";
};
$("resetBtn").onclick = async () => {
if (!confirm("Restore the default Quick links? Your custom entries will be lost.")) return;
if (window.home) await window.home.resetCards();
cards = window.home ? (await window.home.getCards()) : [];
render();
};
function persist() { if (window.home && window.home.setCards) window.home.setCards(cards); }
// Initial load — via IPC when available, otherwise a hardcoded fallback so
// opening home.html directly (e.g. from disk without Electron) still shows
// something instead of a blank grid.
(async () => {
if (window.home && window.home.getCards) cards = await window.home.getCards();
if (!cards || !cards.length) cards = [
{ title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" },
{ title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" },
];
render();
// If main pushes a refreshed list from the remote URL, adopt it — but
// only when the user hasn't customised (main gates this on its side too).
if (window.home && window.home.onCards) {
window.home.onCards((list) => {
if (Array.isArray(list) && list.length) { cards = list; render(); }
});
}
})();
</script>
</body>
</html>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0e14'/%3E%3Cpath d='M32 6 L38 32 L26 32 Z' fill='%23e04e4e'/%3E%3Cpath d='M38 32 L32 58 L26 32 Z' fill='%23d6ff3d'/%3E%3Cpath d='M6 32 L32 27 L58 32 L32 37 Z' fill='%23d6ff3d' opacity='0.55'/%3E%3Ccircle cx='32' cy='32' r='3' fill='%230b0e14'/%3E%3C/svg%3E">
<style>
:root { color-scheme: light dark;
--bg1: #16202e; --bg2: #0b0e14;
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; --warm: #ffc75f; --rose: #f6768a; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100vh; overflow: hidden; font-family: system-ui, sans-serif; color: var(--ink); }
body {
display: flex; flex-direction: column;
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
}
/* Topbar: brand+search+edit. Three-column grid keeps the search centred
even when the brand and edit button expand to different widths. */
.topbar { position: relative; z-index: 10; flex: none; padding: 24px 28px 12px;
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; }
.search { display: flex; justify-content: center; }
.search form { display: flex; gap: 8px; width: 100%; max-width: 560px; }
.search input { flex: 1; padding: 12px 20px; border-radius: 999px; border: 1px solid var(--line2);
background: rgba(20,26,36,.72); color: var(--ink); font-size: 15px; outline: none;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.search input:focus { border-color: var(--blue); }
.search button { padding: 12px 22px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.brandbar { display: flex; align-items: center; gap: 12px; justify-content: flex-end; }
.brandbar .brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-size: 15px; white-space: nowrap; }
.brandbar .brand .mark { width: 24px; height: 24px; display: inline-block; }
.brandbar .brand .g { color: var(--acid); font-weight: 600; }
.brandbar button { background: rgba(20,26,36,.6); border: 1px solid var(--line2); color: var(--mut);
border-radius: 8px; padding: 8px 14px; font-size: 12.5px; cursor: pointer; backdrop-filter: blur(6px); }
.brandbar button:hover { background: var(--card-hi); color: var(--ink); }
.brandbar button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
/* Edit toolbar — floating pill, shown only while editing. */
.editbar { position: absolute; top: 76px; left: 50%; transform: translateX(-50%); z-index: 9;
display: none; gap: 14px; align-items: center; padding: 10px 16px;
background: rgba(20,26,36,.9); border: 1px solid var(--line2); border-radius: 14px;
backdrop-filter: blur(10px); box-shadow: 0 10px 30px #0008; max-width: calc(100vw - 48px); flex-wrap: wrap; }
body.editing .editbar { display: flex; }
.editbar .group { display: flex; align-items: center; gap: 8px; }
.editbar .label { font-size: 11px; color: var(--mut); letter-spacing: .08em; text-transform: uppercase; }
.editbar .sz { display: inline-flex; border: 1px solid var(--line2); border-radius: 999px; overflow: hidden; }
.editbar .sz button { background: transparent; border: 0; color: var(--mut); padding: 6px 10px; font-size: 12px; cursor: pointer; }
.editbar .sz button.on { background: var(--acid); color: #0b0e14; font-weight: 600; }
.editbar .bg { display: inline-flex; gap: 6px; }
.editbar .bg button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
.editbar .bg button.on { outline: 2px solid var(--acid); outline-offset: 1px; }
.editbar input[type=color] { width: 30px; height: 22px; border: 1px solid var(--line2); border-radius: 6px; background: transparent; padding: 0; cursor: pointer; }
.editbar .filebtn { font-size: 11.5px; color: var(--mut); border: 1px dashed var(--line2); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
.editbar .filebtn:hover { color: var(--acid); border-color: var(--acid); }
.editbar .add { background: var(--acid); color: #0b0e14; font-weight: 600; border: none; border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; }
.editbar .reset { background: transparent; border: 1px solid var(--line2); color: var(--mut); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
.editbar .reset:hover { color: var(--rose); border-color: var(--rose); }
/* Two-column canvas: Your links (left) + Silent Mode family (right).
Column widths roughly match the icon counts (right carries the big
curated set). Collapses to one column on narrow windows. Dragging
an icon highlights the column under the cursor as a drop target. */
.desktop { position: relative; flex: 1; overflow: auto; padding: 24px 32px 32px;
display: grid; grid-template-columns: minmax(240px, 2fr) minmax(300px, 3fr);
gap: 36px; align-items: start; }
.column { min-width: 0; }
.section-hdr { display: flex; align-items: baseline; gap: 10px; margin: 4px 4px 14px;
font-size: 11px; color: var(--mut); letter-spacing: .14em; text-transform: uppercase; font-weight: 600; }
.section-hdr .acid { color: var(--acid); }
.section-hdr .count { color: var(--dim); font-weight: 400; letter-spacing: .08em; font-size: 10.5px; }
.section-hdr .rule { flex: 1; height: 1px; background: linear-gradient(to right, var(--line2), transparent); }
.section-grid { display: flex; flex-wrap: wrap; gap: 20px 24px; padding: 10px 4px; align-content: flex-start;
min-height: 140px; border-radius: 14px; }
body.dragging-any .section-grid { outline: 1px dashed var(--line2); outline-offset: 4px; }
body.dragging-any .section-grid.drop-target { outline-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
@media (max-width: 820px) { .desktop { grid-template-columns: 1fr; gap: 20px; } }
/* Icon tile. Brand marks render as inline SVG or an <img src>; user-added
entries fall back to a colored tile with the first letter. */
.icon { display: flex; flex-direction: column; align-items: center; position: relative;
cursor: pointer; user-select: none; text-decoration: none; color: inherit;
transition: transform .12s ease; }
.icon:hover { transform: translateY(-2px); }
.icon .tile { display: flex; align-items: center; justify-content: center;
border-radius: 18px; background: var(--iconBg, #0b0e14);
color: var(--iconFg, #e7eaf1); font-weight: 700; letter-spacing: -.02em;
box-shadow: 0 6px 18px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
overflow: hidden; }
.icon .tile svg, .icon .tile img { width: 72%; height: 72%; display: block; }
.icon .label { margin-top: 7px; font-size: 12.5px; line-height: 1.2; color: #fff;
text-shadow: 0 1px 3px #000c, 0 0 10px #000a; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 2px 6px; border-radius: 4px; }
body.light .icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.icon.size-small .tile { width: 56px; height: 56px; font-size: 22px; border-radius: 14px; }
.icon.size-medium .tile { width: 80px; height: 80px; font-size: 30px; border-radius: 18px; }
.icon.size-large .tile { width: 104px; height: 104px; font-size: 40px; border-radius: 22px; }
.icon.size-xl .tile { width: 136px; height: 136px; font-size: 52px; border-radius: 28px; }
.icon .label { max-width: 120px; }
.icon.size-small .label { max-width: 80px; font-size: 11px; margin-top: 5px; }
.icon.size-large .label { max-width: 140px; font-size: 13px; }
.icon.size-xl .label { max-width: 160px; font-size: 14px; }
.icon .ctrls { position: absolute; top: -8px; right: -8px; display: none; gap: 2px; z-index: 2; }
.icon .ctrls button { width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--line2);
background: rgba(0,0,0,.65); color: #fff; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
.icon .ctrls button.rm:hover { background: var(--rose); border-color: var(--rose); }
.icon .ctrls button.ed:hover { background: var(--blue); border-color: var(--blue); }
body.editing .icon .ctrls { display: flex; }
body.editing .icon { cursor: grab; }
body.editing .icon.dragging { opacity: .85; cursor: grabbing; }
/* "+" slot inside each column — only in edit mode */
.add-slot { display: none; align-items: center; justify-content: center;
border: 2px dashed var(--line2); border-radius: 18px;
color: var(--mut); font-size: 13.5px; cursor: pointer; }
.column.left .add-slot { width: 80px; height: 80px; border-radius: 16px; font-size: 12px; }
.column.right .add-slot { width: 104px; height: 104px; }
.add-slot:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
body.editing .add-slot { display: inline-flex; }
/* Add/Edit modal */
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
align-items: center; justify-content: center; padding: 1rem; backdrop-filter: blur(4px); }
.modal-bg.on { display: flex; }
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 14px;
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
.modal input:focus, .modal select:focus { border-color: var(--blue); }
.modal .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
background: transparent; color: var(--mut); }
.modal button:hover { background: var(--card-hi); color: var(--ink); }
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
@media (prefers-color-scheme: light) {
:root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
--acid: #0AC18E; }
.icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.modal { background: #ffffff; }
}
</style></head>
<body>
<div class="topbar">
<div></div>
<div class="search">
<form id="searchForm">
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
</div>
<div class="brandbar">
<div class="brand">
<span class="mark">
<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 6 L38 32 L26 32 Z" fill="#e04e4e"/>
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
</svg>
</span>
Theseus <span class="g">Navigator</span>
</div>
<button id="editBtn" type="button">Edit desktop</button>
</div>
</div>
<div class="editbar">
<div class="group">
<span class="label">Family size</span>
<div class="sz" data-which="iconSize">
<button data-size="small">S</button>
<button data-size="medium">M</button>
<button data-size="large" class="on">L</button>
<button data-size="xl">XL</button>
</div>
</div>
<div class="group">
<span class="label">Your-links size</span>
<div class="sz" data-which="quickSize">
<button data-size="small">S</button>
<button data-size="medium" class="on">M</button>
<button data-size="large">L</button>
</div>
</div>
<div class="group">
<span class="label">Background</span>
<div class="bg">
<button data-bg="dark" class="on" style="background:#0b0e14" title="Dark"></button>
<button data-bg="indigo" style="background:linear-gradient(135deg,#1e2a4a,#0b0e14)" title="Indigo"></button>
<button data-bg="forest" style="background:linear-gradient(135deg,#132821,#0b140e)" title="Forest"></button>
<button data-bg="rose" style="background:linear-gradient(135deg,#3a1830,#140b14)" title="Rose"></button>
<button data-bg="warm" style="background:linear-gradient(135deg,#3a2a18,#140e0b)" title="Warm"></button>
<button data-bg="light" style="background:linear-gradient(135deg,#dce6f2,#eef0f4)" title="Light"></button>
</div>
<input id="bgColor" type="color" value="#0b0e14" title="Custom color">
<label class="filebtn">Upload image<input id="bgImage" type="file" accept="image/*" style="display:none"></label>
</div>
<div class="group">
<button class="add" id="addBtn">+ Add link</button>
<button class="reset" id="resetBtn" title="Restore the default two-column layout">Reset</button>
</div>
</div>
<div class="desktop" id="desktop">
<div class="column left">
<div class="section-hdr">
<span>Your links</span>
<span class="count" id="qCount"></span>
<span class="rule"></span>
</div>
<div class="section-grid" id="qGrid" data-sec="q"></div>
</div>
<div class="column right">
<div class="section-hdr">
<span class="acid">Silent Mode</span>
<span class="count" id="smCount"></span>
<span class="rule"></span>
</div>
<div class="section-grid" id="smGrid" data-sec="sm"></div>
</div>
</div>
<div class="modal-bg" id="modal">
<div class="modal">
<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/">
<div class="row">
<div>
<label>Column</label>
<select id="mSection">
<option value="q">Your links (left)</option>
<option value="sm">Silent Mode (right)</option>
</select>
</div>
<div>
<label>Size</label>
<select id="mSize">
<option value="">(column default)</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="xl">Extra large</option>
</select>
</div>
</div>
<label>Icon color (used when no brand mark)</label>
<input id="mColor" type="color" value="#d6ff3d" style="width:60px;height:30px;padding:0;cursor:pointer">
<label>Letter / emoji (optional — default = first letter)</label>
<input id="mLetter" placeholder="e.g. H, 🌿, ⛓️" maxlength="4">
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;");
// ---- Brand marks ----
// Keyed by icon.brand slug; value is an inline SVG string or an
// { src: "<relative-url>" } descriptor pointing at a bundled file.
// Entries without a brand fall back to a colored tile with a letter.
const BRAND = {
theseus: `<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 6 L38 32 L26 32 Z" fill="#e04e4e"/><path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/><path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity=".55"/><circle cx="32" cy="32" r="3" fill="#0b0e14"/></svg>`,
silentmode: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><rect x="10" y="22" width="26" height="20" rx="10" fill="none" stroke="#8b98a9" stroke-width="4"/><rect x="28" y="22" width="26" height="20" rx="10" fill="none" stroke="#d6ff3d" stroke-width="4"/></svg>`,
sirius: `<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 4 L36.4 27.6 L60 32 L36.4 36.4 L32 60 L27.6 36.4 L4 32 L27.6 27.6 Z" fill="#d6ff3d"/></svg>`,
hephaestus: `<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 36 h36 l-4 6 h-28 Z" fill="#ffc75f"/><rect x="26" y="42" width="12" height="8" fill="#ffc75f"/><rect x="22" y="50" width="20" height="4" rx="1" fill="#ffc75f"/><rect x="34" y="12" width="4" height="18" rx="1" fill="#8b98a9" transform="rotate(25 36 21)"/><rect x="30" y="12" width="14" height="6" rx="1" fill="#e7eaf1" transform="rotate(25 37 15)"/></svg>`,
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: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M4 28 L32 20 L60 28 L58 32 L32 26 L6 32 Z" fill="#e7eaf1" opacity=".65"/><rect x="14" y="26" width="36" height="22" rx="3" fill="none" stroke="#f6768a" stroke-width="3"/><path d="M14 30 L32 42 L50 30" fill="none" stroke="#f6768a" stroke-width="3"/></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 — real brand mark in ancient-Greek-pottery palette (terracotta
// rings around the cream figure, derived from site-ariadne-x/assets/favicon.svg).
// Bundled under home-brand/ariadne-pottery.svg and referenced relatively.
ariadne: { src: "home-brand/ariadne-pottery.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 — straight from bundled-addons/aegis/addon.json's data: URI,
// decoded as inline SVG so the home tile matches the sidebar icon.
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>`,
hellobch: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><text x="32" y="38" font-family="system-ui" font-weight="800" font-size="18" text-anchor="middle" fill="#8b98a9">hello</text><text x="32" y="54" font-family="system-ui" font-weight="800" font-size="13" text-anchor="middle" fill="#d6ff3d">.bch</text></svg>`,
siacoin: `<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="18" fill="#3ddc97"/><text x="32" y="40" font-family="system-ui" font-weight="800" font-size="22" text-anchor="middle" fill="#0b0e14">S</text></svg>`,
};
// ---- state ----
// v3 schema: { version:3, iconSize, quickSize, background:{kind,value|dataUrl},
// sections: { q:[icon,...], sm:[icon,...] } }
// Icons: { title, url, brand?, color?, letter?, size? }
// `brand` keys into BRAND for an inline SVG or {src} descriptor
// `size` overrides the column's default (small/medium/large/xl)
// `color` + `letter` are only used when brand is absent
// The whole object is stored under userData/home-cards.json via main's
// home-cards-get / home-cards-set IPC. If the stored file is a v1 bare
// array (older schema), main migrates to v3 on first read.
let state = null;
function defaultState() {
return {
version: 3,
iconSize: "large",
quickSize: "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" },
],
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" },
],
},
};
}
function ensureShape(s) {
// Keep whatever the user has, but guarantee the keys the render loop
// reads — tolerate a half-migrated or partial object without crashing.
if (!s || typeof s !== "object") return defaultState();
if (!s.sections || typeof s.sections !== "object") return defaultState();
if (!Array.isArray(s.sections.sm)) s.sections.sm = [];
if (!Array.isArray(s.sections.q)) s.sections.q = [];
if (!s.background) s.background = { kind: "preset", value: "dark" };
if (!s.iconSize) s.iconSize = "large";
if (!s.quickSize) s.quickSize = "medium";
s.version = 3;
return s;
}
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
// ---- background ----
const BG_PRESETS = {
dark: "radial-gradient(1200px 600px at 50% -10%, #16202e, #0b0e14)",
indigo: "linear-gradient(135deg,#1e2a4a 0%, #0b0e14 100%)",
forest: "linear-gradient(135deg,#132821 0%, #0b140e 100%)",
rose: "linear-gradient(135deg,#3a1830 0%, #140b14 100%)",
warm: "linear-gradient(135deg,#3a2a18 0%, #140e0b 100%)",
light: "linear-gradient(135deg,#dce6f2 0%, #eef0f4 100%)",
};
function applyBg() {
const b = state.background || { kind: "preset", value: "dark" };
if (b.kind === "preset") document.body.style.background = BG_PRESETS[b.value] || BG_PRESETS.dark;
else if (b.kind === "color") document.body.style.background = b.value;
else if (b.kind === "image" && b.dataUrl) document.body.style.background = `#0b0e14 center/cover no-repeat url("${b.dataUrl}")`;
const isLight = (b.kind === "preset" && b.value === "light") || (b.kind === "color" && isLightColor(b.value));
document.body.classList.toggle("light", isLight);
document.querySelectorAll(".editbar .bg button").forEach((btn) => {
btn.classList.toggle("on", b.kind === "preset" && btn.dataset.bg === b.value);
});
$("bgColor").value = (b.kind === "color" ? b.value : "#0b0e14");
}
function isLightColor(hex) {
const h = String(hex || "").replace(/^#/, "");
if (h.length !== 6) return false;
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return (r * 0.299 + g * 0.587 + b * 0.114) > 170;
}
// ---- icon render ----
function iconLetter(icon) {
if (icon.letter) return icon.letter;
const t = (icon.title || "").trim();
return (t.match(/^[A-Za-z0-9]/) || ["?"])[0].toUpperCase();
}
function iconArt(icon) {
if (icon.brand && BRAND[icon.brand]) {
const b = BRAND[icon.brand];
if (typeof b === "string") return b;
if (b && b.src) return `<img src="${esc(b.src)}" alt="" draggable="false" style="width:100%;height:100%;object-fit:contain">`;
}
return esc(iconLetter(icon));
}
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
function render() {
for (const sec of ["sm", "q"]) {
const grid = $(sec === "sm" ? "smGrid" : "qGrid");
const defaultSize = sec === "sm" ? state.iconSize : state.quickSize;
const list = state.sections[sec] || [];
$(sec === "sm" ? "smCount" : "qCount").textContent = `· ${list.length}`;
grid.innerHTML = "";
list.forEach((ic, i) => {
const size = ic.size || defaultSize;
const el = document.createElement("a");
el.className = `icon size-${size}`;
el.href = ic.url;
el.dataset.sec = sec; el.dataset.i = i;
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="ctrls">
<button class="ed" title="Edit">&#9998;</button>
<button class="rm" title="Remove">&times;</button>
</div>
<div class="tile">${iconArt(ic)}</div>
<div class="label">${esc(ic.title || ic.url)}</div>`;
el.addEventListener("click", (e) => {
if (document.body.classList.contains("editing")) {
e.preventDefault();
if (e.target.classList.contains("rm")) return removeIcon(sec, i);
if (e.target.classList.contains("ed")) return openModal(sec, i);
return;
}
e.preventDefault();
if (window.home && window.home.navigate) window.home.navigate(ic.url);
else window.location.href = ic.url;
});
el.addEventListener("pointerdown", (e) => startDrag(e, sec, i, el));
grid.appendChild(el);
});
const add = document.createElement("button");
add.className = "add-slot";
add.textContent = "+";
add.title = sec === "q" ? "Add to Your links" : "Add to Silent Mode";
add.onclick = () => openModal(sec, -1);
grid.appendChild(add);
}
}
// ---- drag + drop (within and across columns) ----
let drag = null;
function startDrag(e, sec, i, el) {
if (!document.body.classList.contains("editing")) return;
if (e.target.closest(".ctrls")) return;
e.preventDefault();
drag = { sec, i, el };
el.classList.add("dragging");
document.body.classList.add("dragging-any");
el.setPointerCapture(e.pointerId);
}
window.addEventListener("pointermove", (e) => {
if (!drag) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const over = target && target.closest(".section-grid");
document.querySelectorAll(".section-grid.drop-target").forEach((g) => { if (g !== over) g.classList.remove("drop-target"); });
if (over) over.classList.add("drop-target");
});
window.addEventListener("pointerup", (e) => {
if (!drag) return;
const el = drag.el;
const target = document.elementFromPoint(e.clientX, e.clientY);
const targetGrid = target && target.closest(".section-grid");
const targetSec = targetGrid && targetGrid.dataset ? targetGrid.dataset.sec : null;
if (targetSec && (targetSec === "sm" || targetSec === "q")) {
const siblings = [...targetGrid.querySelectorAll(".icon")];
let insertAt = siblings.length;
for (let k = 0; k < siblings.length; k++) {
const r = siblings[k].getBoundingClientRect();
if (e.clientX < r.left + r.width / 2 && e.clientY < r.bottom) { insertAt = k; break; }
}
const [moved] = state.sections[drag.sec].splice(drag.i, 1);
if (targetSec === drag.sec && insertAt > drag.i) insertAt--;
state.sections[targetSec].splice(insertAt, 0, moved);
persist(); render();
}
document.querySelectorAll(".section-grid.drop-target").forEach((g) => g.classList.remove("drop-target"));
document.body.classList.remove("dragging-any");
el.classList.remove("dragging");
drag = null;
});
// ---- add / edit / delete ----
let editKey = null;
function openModal(sec, i) {
editKey = { sec, i };
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add link" : "Edit link";
const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "", size: "" }
: state.sections[sec][i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSection").value = sec;
$("mSize").value = c.size || "";
$("mColor").value = c.color || "#d6ff3d";
$("mLetter").value = c.letter || "";
$("modal").classList.add("on");
setTimeout(() => $("mTitle").focus(), 20);
}
function closeModal() { $("modal").classList.remove("on"); }
function removeIcon(sec, i) {
if (!confirm(`Remove "${state.sections[sec][i].title}" from the desktop?`)) return;
state.sections[sec].splice(i, 1); persist(); render();
}
$("mCancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
$("mSave").onclick = () => {
const title = $("mTitle").value.trim();
let url = $("mUrl").value.trim();
if (!title || !url) { alert("Title and URL are required."); return; }
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const next = { title, url, color: $("mColor").value, letter: $("mLetter").value.trim() || null, size: $("mSize").value || null };
const toSec = $("mSection").value;
if (!editKey || editKey.i < 0) {
state.sections[toSec].push(next);
} else {
const prev = state.sections[editKey.sec][editKey.i];
const merged = { ...prev, ...next, brand: prev.brand || null };
if (toSec === editKey.sec) state.sections[toSec][editKey.i] = merged;
else { state.sections[editKey.sec].splice(editKey.i, 1); state.sections[toSec].push(merged); }
}
persist(); render(); closeModal();
};
// ---- edit toggle + toolbar ----
$("editBtn").onclick = () => {
const editing = document.body.classList.toggle("editing");
$("editBtn").classList.toggle("on", editing);
$("editBtn").textContent = editing ? "Done" : "Edit desktop";
};
$("addBtn").onclick = () => openModal("q", -1);
$("resetBtn").onclick = async () => {
if (!confirm("Restore the default desktop? Your added links will be removed.")) return;
if (window.home && window.home.resetCards) await window.home.resetCards();
state = defaultState();
applyBg(); applySizeButtons(); render();
persist();
};
document.querySelectorAll(".editbar .sz").forEach((group) => {
const which = group.dataset.which; // iconSize | quickSize
group.querySelectorAll("button").forEach((btn) => {
btn.onclick = () => {
state[which] = btn.dataset.size;
group.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
persist(); render();
};
});
});
document.querySelectorAll(".editbar .bg button[data-bg]").forEach((btn) => {
btn.onclick = () => { state.background = { kind: "preset", value: btn.dataset.bg }; applyBg(); persist(); };
});
$("bgColor").oninput = () => { state.background = { kind: "color", value: $("bgColor").value }; applyBg(); persist(); };
$("bgImage").onchange = (e) => {
const f = e.target.files && e.target.files[0]; if (!f) return;
if (f.size > 2_000_000) { alert("Please pick an image under 2 MB."); return; }
const r = new FileReader();
r.onload = () => { state.background = { kind: "image", dataUrl: r.result }; applyBg(); persist(); };
r.readAsDataURL(f);
};
// ---- search form ----
// Submit goes through main (which applies the user's default engine + locale).
$("searchForm").addEventListener("submit", (e) => {
e.preventDefault();
const q = $("q").value.trim(); if (!q) return;
if (window.home && window.home.navigate) window.home.navigate(q);
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q);
});
// Reflect the current default search engine in the placeholder, so the
// input reads "Search the web with <engine>" instead of being generic.
if (window.home && window.home.getEngines) {
window.home.getEngines().then((r) => {
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name;
if (name) $("q").placeholder = "Search the web with " + name;
}).catch(() => {});
}
function applySizeButtons() {
document.querySelectorAll(".editbar .sz").forEach((group) => {
const which = group.dataset.which;
group.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.size === state[which]));
});
}
// ---- initial load ----
(async () => {
let loaded = null;
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
state = ensureShape(loaded) || defaultState();
applyBg(); applySizeButtons(); render();
// Main may push a fresher state (remote-cache refresh succeeded) via onCards.
if (window.home && window.home.onCards) {
window.home.onCards((data) => {
const next = ensureShape(data);
if (next) { state = next; applyBg(); applySizeButtons(); render(); }
});
}
})();
</script>
</body>
</html>

151
main.js
View file

@ -832,54 +832,112 @@ function emitUpdateAvailable() {
sendAllChrome("update-available", payload);
}
// ---- home page editable cards (userData/home-cards.json) ------------------
// Rendered by home.html as the "quick links" grid on the new-tab page. User
// can add/edit/remove via the page's edit mode. First-run seed = the classic
// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.).
const DEFAULT_HOME_CARDS = [
{ title: "hello.bch", url: "https://hello.bch/", sub: "A small page on the blockchain itself.", badge: "on-chain" },
{ title: "siatest.bch", url: "https://siatest.bch/", sub: "A page with no server, backed by Sia.", badge: "Sia" },
{ title: "SilentMode.X", url: "https://silentmode.x/", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" },
{ title: "Theseus.X", url: "https://theseus.x/", sub: "The Web Navigator — this browser's own address.", badge: "Navigator" },
{ title: "Sirius.X", url: "https://sirius.x/", sub: "Register and manage BCDN names.", badge: "Registrar" },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", sub: "The forge — Silent Mode's code host.", badge: "Code host" },
{ title: "Prometheus.X", url: "https://prometheus.x/", sub: "Decentralized App Marketplace.", badge: "App store" },
{ title: "Helios.X", url: "https://helios.x/", sub: "Search engine for the decentralized web (in design).", badge: "Search" },
{ title: "Hermes.X", url: "https://hermes.x/", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" },
];
// User's local edits win over everything else — that's the whole point of
// the edit mode. Remote pull only feeds the "defaults" tier so brand copy
// changes reach installs without a browser release.
// ---- home page editable state (userData/home-cards.json) ------------------
// Rendered by home.html as the two-column desktop on the new-tab page (left
// = user's own links, right = Silent Mode X-family). User can add / edit /
// remove / drag via the page's edit mode. First-run seed = the curated
// defaults below.
//
// Schema v3 is an object:
// { version:3, iconSize, quickSize, background:{...},
// sections: { sm:[...], q:[...] } }
// A v1 bare array (the pre-0.3.90 schema) is still accepted on read and
// migrated into sections.q (user's own links) on first save. The remote URL
// still serves an array — see migrateCards() for how that lands into v3.
const DEFAULT_HOME_STATE = {
version: 3,
iconSize: "large",
quickSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
// Right column — the curated Silent Mode X-family lineup.
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" },
],
// Left column — user's own quick links. Seed includes the demos plus
// Pithos.sia + Bergamot.X (tools-you-use, not apex names).
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" },
],
},
};
const homeCardsFile = () => path.join(app.getPath("userData"), "home-cards.json");
const homeCardsRemoteCache = () => path.join(app.getPath("userData"), "home-cards-remote.json");
// The canonical remote card list is served from silentmode.st (and mirrored
// on silentmode.bch via Sia). Editing that file updates every install on
// its next launch — no reinstall required.
const HOME_CARDS_URL = "https://dl.silentmode.st/home-cards.json";
const HOME_CARDS_REFRESH_MS = 6 * 60 * 60 * 1000; // every 6h
function loadHomeCards() {
// Keep a stable v3-shaped clone of the defaults so a caller mutating what we
// hand them (edit-mode runs do that) never pollutes the module-level seed.
function cloneDefaults() { return JSON.parse(JSON.stringify(DEFAULT_HOME_STATE)); }
// Promote whatever shape we read on disk / off the wire into v3. Accepts:
// - a v3 object (returned as-is after shape repair);
// - a v1 bare array of {title,url,sub?,badge?} cards (lands in sections.q);
// - anything else -> null, and the caller falls back to defaults.
function migrateCards(raw) {
if (!raw) return null;
if (Array.isArray(raw)) {
const q = raw.filter((c) => c && typeof c.title === "string" && typeof c.url === "string")
.map((c) => ({ title: c.title, url: c.url }));
if (!q.length) return null;
const next = cloneDefaults();
next.sections.q = q; // overlay the user's old picks onto the default SM column
return next;
}
if (typeof raw !== "object") return null;
if (!raw.sections || typeof raw.sections !== "object") return null;
const s = {
version: 3,
iconSize: raw.iconSize || "large",
quickSize: raw.quickSize || "medium",
background: (raw.background && typeof raw.background === "object") ? raw.background : { kind: "preset", value: "dark" },
sections: {
sm: Array.isArray(raw.sections.sm) ? raw.sections.sm : [],
q: Array.isArray(raw.sections.q) ? raw.sections.q : [],
},
};
return s;
}
function loadHomeState() {
// Priority: user's local edits > cached remote copy > code defaults.
try {
if (fs.existsSync(homeCardsFile())) {
const v = JSON.parse(fs.readFileSync(homeCardsFile(), "utf8"));
if (Array.isArray(v) && v.length) return v;
const m = migrateCards(v);
if (m) return m;
}
} catch (e) { console.error("home cards (user) load failed:", e.message); }
} catch (e) { console.error("home state (user) load failed:", e.message); }
try {
if (fs.existsSync(homeCardsRemoteCache())) {
const v = JSON.parse(fs.readFileSync(homeCardsRemoteCache(), "utf8"));
if (Array.isArray(v) && v.length) return v;
const m = migrateCards(v);
if (m) return m;
}
} catch (e) { console.error("home cards (remote-cache) load failed:", e.message); }
return DEFAULT_HOME_CARDS.slice();
} catch (e) { console.error("home state (remote-cache) load failed:", e.message); }
return cloneDefaults();
}
function saveHomeCards(cards) {
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(cards, null, 2)); }
catch (e) { console.error("home cards save failed:", e.message); }
function saveHomeState(state) {
const m = migrateCards(state); if (!m) return;
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(m, null, 2)); }
catch (e) { console.error("home state save failed:", e.message); }
}
// Fetch the canonical home-cards.json into the remote cache. Silent on any
// error (no network, 404, bad JSON, etc.) — the cache stays as-is and the
// user sees either their last cached set or the built-in defaults.
// Legacy aliases — some older callers (and the IPC handler names) still read
// as cards. New code should call loadHomeState / saveHomeState directly.
function loadHomeCards() { return loadHomeState(); }
function saveHomeCards(x) { return saveHomeState(x); }
// Fetch the canonical home-cards.json into the remote cache. The remote may
// serve either an array (v1) or a v3 object — migrateCards() handles both.
// Silent on any error — the cache stays as-is and the user keeps seeing
// either their last cached set or the built-in defaults.
async function refreshRemoteHomeCards() {
try {
const controller = new AbortController();
@ -887,20 +945,19 @@ async function refreshRemoteHomeCards() {
const r = await fetch(HOME_CARDS_URL, { signal: controller.signal, cache: "no-store" });
clearTimeout(to);
if (!r.ok) return;
const list = await r.json();
if (!Array.isArray(list) || list.length === 0) return;
// Basic sanity: every entry must be an object with a string title + url.
const clean = list.filter((c) => c && typeof c.title === "string" && typeof c.url === "string");
if (!clean.length) return;
fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(clean, null, 2));
const raw = await r.json();
const next = migrateCards(raw);
if (!next) return;
fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(next, null, 2));
// Only push into open home tabs if the user hasn't overridden — their
// edits stay put.
// edits stay put. The home page's onCards handler re-renders.
if (!fs.existsSync(homeCardsFile())) {
for (const t of tabs) {
try { t.view.webContents.send("home-cards", clean); } catch {}
try { t.view.webContents.send("home-cards", next); } catch {}
}
}
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`);
const smN = next.sections.sm.length, qN = next.sections.q.length;
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${smN + qN} total (sm=${smN}, q=${qN})`);
} catch (e) { /* silent */ }
}
// Sender validation — only accept IPC from our own app pages. Compared against
@ -7571,8 +7628,14 @@ app.on("will-quit", () => {
});
// Home page editable cards. Origin-gated to home.html — random pages that
// snoop the preload can't act on the local file.
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; });
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeState() : null);
// Accepts either the v3 state object or the legacy v1 array — migrateCards()
// normalises both into v3 before persisting (saveHomeState clones through it).
ipcMain.handle("home-cards-set", (e, state) => {
if (!isHomePageSender(e.sender)) return false;
if (!state || typeof state !== "object") return false;
saveHomeState(state); return true;
});
ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; });
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.89",
"version": "0.3.90",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
@ -109,6 +109,7 @@
"panel-header-preload.js",
"panel-grip.html",
"engine-icons/**/*",
"home-brand/**/*",
"lib/**/*",
"package.json",
"node_modules/**/*",