theseus/home.html
Local Dev 2abd4d9616 hermes: crop the mark to the icon area, ship 128/256 px PNGs
The 1461x720 webp downscales badly to 24 px (sidebar strip) and 72 px (signin
header) — browser is doing a ~60x reduction and the thin wing lines turn to
mush. Pre-crop around the icon's content bbox, make it square with a small
breathing margin, resize to 128 px and 256 px, drop transparency on the dark
backdrop. Addon manifest icon is the 128 PNG as a data URI (16 KB vs the
26 KB uncropped webp), and the panel pulls mark-128.png / mark-256.png at
the two sizes it needs. Also home.html's Hermes.X tile now points at the
cropped PNG so the home card isn't blurry.

Version bumped to 0.2.3 so seedBundledAddons picks it up on a release
bundle swap. The installed bundle + user-data cache were hot-patched
too so the fix is visible immediately in the running app.
2026-10-21 22:14:00 +03:00

752 lines
42 KiB
HTML

<!doctype html>
<html>
<head><meta charset="utf-8"><title>Theseus</title>
<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; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); }
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
/* Hero (identity + search). Centered at the top of the page — the user's
most-used thing on this screen. The slot/card desktop lives beneath. */
.hero { text-align: center; padding: 5vh 1rem 0; max-width: 760px; margin: 0 auto; }
.hero .mark { display: inline-block; width: 64px; height: 64px; line-height: 1; }
.hero .mark svg { width: 100%; height: 100%; display: block; }
.hero h1 { font-size: 2.4rem; margin: .3rem 0 .2rem; letter-spacing: .5px; font-weight: 600; }
.hero h1 .g { color: var(--acid); }
.hero .tag { color: var(--mut); margin: 0 0 1.4rem; }
.hero form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
.hero form input { flex: 1; padding: 13px 18px; 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); }
.hero form input:focus { border-color: var(--blue); }
.hero form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hero .hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
/* Two-column desktop under the hero: a 3-column grid of icon slots on the
LEFT, a 3-column grid of Silent Mode cards on the RIGHT. Both expand
together on wider screens; the layout stretches to the viewport so the
left column doesn't crowd against the cards. */
.homepage { display: grid; grid-template-columns: minmax(300px, 360px) minmax(380px, 1fr);
gap: 36px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 40px 48px; align-items: start; }
@media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
/* LEFT AREA — fixed-cell grid. Each icon lives in its own cell; drag
reorders which icon is in which cell. Simpler than free x,y and keeps
the icons tidy against the left edge of the column. */
.left-column { display: flex; flex-direction: column; gap: 14px; }
.slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; align-items: start; }
@media (max-width: 900px) { .slots-list { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); } }
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
.slot { position: relative; 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); }
.slot .tile { display: flex; align-items: center; justify-content: center;
border-radius: 16px; 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; }
.slot .tile svg, .slot .tile img { width: 72%; height: 72%; display: block; }
.slot .label { margin-top: 6px; font-size: 11.5px; line-height: 1.2; color: #fff;
text-shadow: 0 1px 3px #000c, 0 0 10px #000a; max-width: 100px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 2px 6px; border-radius: 4px; }
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.slot.size-small .tile { width: 56px; height: 56px; font-size: 22px; border-radius: 12px; }
.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-add sits in the grid alongside the slot icons so it's always at
the end of the row, not pinned to a corner. */
.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: 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); }
/* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search
lives in the hero above. */
.right-column { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
.cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; }
@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: 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; }
.card:hover { background: var(--card-hi); border-color: rgba(75,123,236,.4); transform: translateY(-1px); }
.card .card-ico { flex: none; width: 44px; height: 44px; border-radius: 10px;
background: var(--iconBg, transparent);
display: flex; align-items: center; justify-content: center;
color: var(--iconFg, #e7eaf1); font-weight: 700; font-size: 20px;
overflow: hidden; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
.card .card-ico svg, .card .card-ico img { width: 100%; height: 100%; display: block; }
.card .card-title { font-size: 14px; font-weight: 600; color: var(--ink); text-align: center;
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;
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
}
.card.drag-before::before { left: -8px; }
.card.drag-after::after { right: -8px; }
.card-add { display: flex; align-items: center; justify-content: center; min-height: 128px;
border: 2px dashed var(--line2); border-radius: 14px; background: transparent;
color: var(--mut); font-size: 13.5px; cursor: pointer;
transition: color .12s, border-color .12s, background .12s; }
.card-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
/* Context menus. One for the background (edit actions), one for images
(copy / edit / remove). Positioned at the pointer by JS. */
.ctx-menu { position: fixed; display: none; z-index: 1000; min-width: 200px;
background: rgba(20,26,36,.96); border: 1px solid var(--line2); border-radius: 10px;
padding: 6px; box-shadow: 0 16px 40px #000a; backdrop-filter: blur(10px); }
.ctx-menu.on { display: block; }
.ctx-menu button, .ctx-menu .submenu-trigger {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; text-align: left; background: transparent; border: 0;
color: var(--ink); padding: 8px 12px; border-radius: 6px; cursor: pointer;
font: inherit; font-size: 13px; }
.ctx-menu button:hover, .ctx-menu .submenu-trigger:hover { background: var(--card-hi); }
.ctx-menu button.danger { color: var(--rose); }
.ctx-menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
.ctx-menu .caret { color: var(--dim); font-size: 11px; }
.ctx-menu .value { color: var(--mut); font-size: 12px; }
/* Background chooser modal (opened from the context menu) */
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 1100;
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); }
.bg-preset-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 6px; }
.bg-preset-row button { height: 44px; border-radius: 8px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
.bg-preset-row button.on { outline: 2px solid var(--acid); outline-offset: 1px; }
.bg-color-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.bg-color-row input[type=color] { width: 44px; height: 32px; border: 1px solid var(--line2); border-radius: 6px; background: transparent; padding: 0; cursor: pointer; }
.bg-file { font-size: 12.5px; color: var(--mut); border: 1px dashed var(--line2); border-radius: 6px; padding: 6px 12px; cursor: pointer; }
.bg-file:hover { color: var(--acid); border-color: var(--acid); }
.toast { position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
background: rgba(20,26,36,.96); color: var(--ink); padding: 10px 18px; border-radius: 10px;
border: 1px solid var(--line2); font-size: 13px; opacity: 0; pointer-events: none;
transition: opacity .18s, transform .18s; z-index: 1200; }
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
@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; }
.slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.modal, .ctx-menu { background: #ffffff; }
}
</style>
</head>
<body>
<div class="hero">
<div 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>
</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" 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="homepage">
<div class="left-column">
<div class="slots-list" id="slotsList"></div>
</div>
<div class="right-column">
<div class="cards-grid" id="cardsGrid"></div>
</div>
</div>
<div class="ctx-menu" id="ctxBg">
<button data-action="change-bg">Change background&hellip;</button>
<button class="submenu-trigger" data-action="icon-size">
<span>Icon size</span><span class="value" id="ctxIconSizeVal">medium</span>
</button>
<hr>
<button data-action="add-link">Add link (left column)</button>
<button data-action="add-card">Add card (Silent Mode)</button>
<hr>
<button class="danger" data-action="reset">Reset to defaults</button>
</div>
<div class="ctx-menu" id="ctxItem">
<button data-action="open">Open</button>
<button data-action="open-new">Open in new tab</button>
<hr>
<button data-action="copy-image">Copy image</button>
<button data-action="copy-url">Copy link address</button>
<hr>
<button data-action="edit">Edit&hellip;</button>
<button class="danger" data-action="remove">Remove</button>
</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/">
<label>Description (shown on cards)</label>
<input id="mSub" placeholder="one short line — optional">
<div class="row">
<div>
<label>Column</label>
<select id="mSection">
<option value="q">Left column (slots)</option>
<option value="sm">Silent Mode (3-column grid)</option>
</select>
</div>
<div>
<label>Icon color (fallback)</label>
<input id="mColor" type="color" value="#d6ff3d">
</div>
</div>
<label>Letter / emoji (optional)</label>
<input id="mLetter" placeholder="first letter is used by default" maxlength="4">
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div>
</div>
<div class="modal-bg" id="bgModal">
<div class="modal">
<h2>Background</h2>
<label>Preset</label>
<div class="bg-preset-row">
<button data-bg="dark" 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>
<label>Custom color</label>
<div class="bg-color-row">
<input id="bgColor" type="color" value="#0b0e14">
<label class="bg-file">Upload image<input id="bgImage" type="file" accept="image/*" style="display:none"></label>
</div>
<div class="btns"><button id="bgClose" class="primary" type="button">Done</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;");
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 toast(msg) {
const t = $("toast"); t.textContent = msg; t.classList.add("on");
clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove("on"), 1800);
}
// Brand marks — inline SVG strings or { src } references to files bundled
// under home-brand/. Fall back to a letter tile when the icon has no brand.
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: { src: "home-brand/hermes-mark.png" },
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" },
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>`,
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>`,
};
// v5 schema. Dropped the per-item x,y that v4 used for free-drag — left
// column is a vertical stack now, order within the array is the order on
// screen. Legacy x,y values are ignored by render() but preserved in
// storage (migrateCards treats items as opaque).
let state = null;
function defaultState() {
return {
version: 6,
iconSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
sm: [
{ 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 live in fixed grid cells; drag reorders which
// icon is in which cell. Order in the array = position in the grid
// (row-first, 3 per row).
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) {
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 = "medium";
return s;
}
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
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("#bgModal .bg-preset-row 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;
}
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() {
// 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.
const q = state.sections.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");
} 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);
});
const slotAdd = document.createElement("button");
slotAdd.className = "slot-add"; slotAdd.textContent = "+"; slotAdd.title = "Add link";
slotAdd.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openModal("q", -1); };
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 = "";
sm.forEach((ic, i) => {
const el = document.createElement("a");
el.className = "card";
el.href = ic.url; el.dataset.sec = "sm"; el.dataset.i = i;
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
}
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");
add.className = "card-add"; add.textContent = "+ Add card";
add.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openModal("sm", -1); };
add.oncontextmenu = (e) => e.preventDefault();
grid.appendChild(add);
$("ctxIconSizeVal").textContent = state.iconSize;
}
// 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); }
function wireCard(el, i) { wireGridItem(el, "sm", i); }
function wireGridItem(el, sec, i) {
el.draggable = true;
el.addEventListener("click", (e) => {
e.preventDefault();
const url = state.sections[sec][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);
});
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec, i }));
el.classList.add("dragging");
hideAllCtx();
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
document.querySelectorAll(".drag-before,.drag-after")
.forEach((n) => n.classList.remove("drag-before", "drag-after"));
});
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect();
const before = e.clientX < r.left + r.width / 2;
el.classList.toggle("drag-before", before);
el.classList.toggle("drag-after", !before);
});
el.addEventListener("dragleave", () => {
el.classList.remove("drag-before", "drag-after");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
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);
});
}
// Container-level drops handle cross-section drops onto empty space —
// the item appends to the target section's grid.
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);
});
}
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
function moveItem(fromSec, fromI, toSec, toIdx) {
const [m] = state.sections[fromSec].splice(fromI, 1);
if (fromSec === toSec && toIdx > fromI) toIdx--;
toIdx = Math.max(0, Math.min(state.sections[toSec].length, toIdx));
state.sections[toSec].splice(toIdx, 0, m);
persist(); render();
}
// Context menus. Right-click on an empty area shows the background menu;
// right-click on an icon/card shows the item menu (copy image, edit, …).
let ctxTarget = null;
function hideAllCtx() { $("ctxBg").classList.remove("on"); $("ctxItem").classList.remove("on"); ctxTarget = null; }
function positionMenu(menu, x, y) {
menu.classList.add("on");
const r = menu.getBoundingClientRect();
const nx = Math.min(x, window.innerWidth - r.width - 8);
const ny = Math.min(y, window.innerHeight - r.height - 8);
menu.style.left = nx + "px"; menu.style.top = ny + "px";
}
function openCtxBg(x, y) {
hideAllCtx();
$("ctxIconSizeVal").textContent = state.iconSize;
positionMenu($("ctxBg"), x, y);
}
function openCtxItem(x, y, sec, i) {
hideAllCtx();
ctxTarget = { sec, i };
positionMenu($("ctxItem"), x, y);
}
document.addEventListener("contextmenu", (e) => {
// Default: background menu. Items override this with their own handler.
e.preventDefault();
openCtxBg(e.clientX, e.clientY);
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".ctx-menu")) hideAllCtx();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideAllCtx(); closeModal(); closeBgModal(); } });
window.addEventListener("blur", hideAllCtx);
window.addEventListener("scroll", hideAllCtx, true);
$("ctxBg").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action]"); if (!btn) return;
const a = btn.dataset.action;
hideAllCtx();
if (a === "change-bg") $("bgModal").classList.add("on");
else if (a === "icon-size") {
const next = state.iconSize === "small" ? "medium" : state.iconSize === "medium" ? "large" : "small";
state.iconSize = next; persist(); render();
toast("Icon size: " + next);
}
else if (a === "add-link") openModal("q", -1);
else if (a === "add-card") openModal("sm", -1);
else if (a === "reset") {
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(); render(); persist();
}
});
$("ctxItem").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action]"); if (!btn) return;
if (!ctxTarget) { hideAllCtx(); return; }
const { sec, i } = ctxTarget;
const item = state.sections[sec][i]; if (!item) { hideAllCtx(); return; }
const a = btn.dataset.action;
hideAllCtx();
if (a === "open") {
if (window.home && window.home.navigate) window.home.navigate(item.url);
else window.location.href = item.url;
} else if (a === "open-new") {
if (window.home && window.home.openInNewTab) window.home.openInNewTab(item.url);
else window.open(item.url, "_blank");
} else if (a === "copy-image") {
await copyImage(item);
} else if (a === "copy-url") {
try { await navigator.clipboard.writeText(item.url); toast("Link copied"); }
catch { toast("Copy failed"); }
} else if (a === "edit") {
openModal(sec, i);
} else if (a === "remove") {
if (!confirm(`Remove "${item.title}"?`)) return;
state.sections[sec].splice(i, 1); persist(); render();
}
});
// Render the brand mark to a PNG blob and write it to the clipboard as an
// image. SVG strings get base64-encoded for the <img> source. {src: …}
// brands are loaded straight from the bundled file.
async function copyImage(item) {
try {
const b = (item.brand && BRAND[item.brand]) || null;
let srcUrl;
if (typeof b === "string") {
srcUrl = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(b)));
} else if (b && b.src) {
srcUrl = b.src;
} else {
// Letter tile — render a 128px square with the icon color + letter.
srcUrl = letterTileToDataURL(item);
}
const png = await imageToPngBlob(srcUrl, 256);
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
toast("Image copied");
} catch (err) {
console.error("copy image failed", err);
toast("Copy failed");
}
}
function imageToPngBlob(url, size) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("toBlob null")), "image/png");
};
img.onerror = (e) => reject(e);
img.src = url;
});
}
function letterTileToDataURL(item) {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d");
const bg = item.color || "#d6ff3d";
const fg = isLightColor(bg) ? "#0b0e14" : "#ffffff";
const r = 48;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.moveTo(r, 0); ctx.lineTo(size - r, 0); ctx.quadraticCurveTo(size, 0, size, r);
ctx.lineTo(size, size - r); ctx.quadraticCurveTo(size, size, size - r, size);
ctx.lineTo(r, size); ctx.quadraticCurveTo(0, size, 0, size - r);
ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = fg;
ctx.font = "bold 140px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(iconLetter(item), size / 2, size / 2 + 10);
return canvas.toDataURL("image/png");
}
// Add/edit modal — the same shape works for both left slots and right cards.
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: "", 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 || "";
$("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(); });
$("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,
sub: $("mSub").value.trim() || null,
color: $("mColor").value,
letter: $("mLetter").value.trim() || 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();
};
// Background chooser modal
function closeBgModal() { $("bgModal").classList.remove("on"); }
$("bgClose").onclick = closeBgModal;
$("bgModal").addEventListener("click", (e) => { if (e.target.id === "bgModal") closeBgModal(); });
document.querySelectorAll("#bgModal .bg-preset-row button").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 (slot-add button is rendered inside slotsList per render())
$("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);
});
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(() => {});
}
// Container-level drop handlers accept cross-column drops onto empty space.
wireContainer($("slotsList"), "q");
wireContainer($("cardsGrid"), "sm");
// Initial load. If the stored state has x,y on q items (v4 free-drag),
// we just ignore them — their order in the array is their order on screen.
(async () => {
let loaded = null;
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
state = ensureShape(loaded) || defaultState();
applyBg(); render();
if (window.home && window.home.onCards) {
window.home.onCards((data) => {
const next = ensureShape(data);
if (next) { state = next; applyBg(); render(); }
});
}
})();
</script>
</body>
</html>