theseus/home.html
Silent Mode 8ac8b549b9 Theseus 0.3.91: home — hero back, Silent Mode cards, free-drag Your-links
Three changes to the desktop home that landed in 0.3.90:

- Hero header is back. The compass mark, Theseus Navigator title, tagline
  and search sit centered at the top of the page, same shape as the home
  before the desktop redesign. The Edit button moves to the top-right
  corner so the hero reads as identity, not chrome.

- Silent Mode on the right is CARDS again, not just tiles. Each card
  carries the brand icon on the left, title + one-line description
  stacked, and a muted acid-colored badge on the right (Navigator /
  Registrar / Code host / ...). Cards sit in a center-right column that
  stops before the right edge, giving the left column breathing room.

- Your-links on the left is free-drag, OS-desktop style. Each icon
  stores x,y and snaps to a 20px grid; dragging an icon moves it live
  while the grid faintly shows underneath. The schema gains x,y on
  Your-links items — unchanged for Silent Mode cards. Legacy icons
  without x,y auto-layout in a tidy 2-column grid near the top.

Compass mark replaces the chain emoji in the hero. The on-disk schema
nudges to v4; v3 and v1 reads still migrate (missing x,y get an
auto-layout fallback at render time).
2026-10-12 09:38:09 +02:00

681 lines
41 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 {
display: flex; flex-direction: column;
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
}
/* Hero (identity + search). Centered at the top of the page, same shape
as the pre-desktop home — this is what the user sees most often. */
.hero { text-align: center; padding: 5vh 1rem 0; }
.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; }
.corner { position: absolute; top: 18px; right: 18px; z-index: 10; }
.corner 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); }
.corner button:hover { background: var(--card-hi); color: var(--ink); }
.corner button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
/* Edit toolbar (shown only while editing) */
.editbar { display: none; margin: 1.6rem auto .4rem; 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; width: fit-content; 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 under the hero: narrow LEFT for icon tiles,
wider RIGHT for Silent Mode cards. Not full width — centered with
breathing room on both sides. Collapses to one column under 820px. */
.desktop { position: relative; width: 100%; max-width: 1180px; margin: 2.2rem auto 3rem; padding: 0 2rem;
display: grid; grid-template-columns: minmax(180px, 220px) minmax(380px, 760px);
gap: 44px; align-items: start; justify-content: center; }
.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); }
body.dragging-any .section-grid, body.dragging-any .cards-grid { outline: 1px dashed var(--line2); outline-offset: 4px; border-radius: 14px; }
body.dragging-any .section-grid.drop-target, body.dragging-any .cards-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; padding: 0 1rem; } }
/* LEFT column — OS-desktop style: icons live at absolute x,y positions
inside the column area so the user can drag them anywhere. The grid
is a faint 20px snap grid that appears while dragging. Minimum height
keeps the area tall enough that the first drag has somewhere to go. */
.section-grid { position: relative; padding: 8px 4px; min-height: 420px; border-radius: 10px; }
.section-grid::before {
content: ""; position: absolute; inset: 0; opacity: 0;
background-image: linear-gradient(to right, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.04) 1px, transparent 1px);
background-size: 20px 20px; pointer-events: none; transition: opacity .15s;
border-radius: 10px;
}
body.dragging-any .section-grid::before { opacity: 1; }
.section-grid .icon { position: absolute; }
.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: 72px; height: 72px; font-size: 28px; border-radius: 16px; }
.icon.size-large .tile { width: 96px; height: 96px; font-size: 36px; border-radius: 20px; }
.icon .label { max-width: 100px; }
.icon.size-small .label { max-width: 72px; font-size: 11px; margin-top: 5px; }
/* Silent Mode CARDS — icon + title + sub + badge, one per row */
.cards-grid { display: flex; flex-direction: column; gap: 10px; padding: 10px 0; min-height: 140px; }
.card { display: flex; align-items: center; gap: 14px; padding: 14px 18px;
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
text-decoration: none; color: inherit; cursor: pointer;
transition: transform .12s, background .12s, border-color .12s; position: relative; }
.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-body { flex: 1; min-width: 0; }
.card .card-title { font-size: 15px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 { color: var(--mut); font-size: 13px; margin-top: 3px; line-height: 1.4;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
.card:hover .card-sub { color: var(--ink); }
.card .card-badge { flex: none; font-size: 10.5px; padding: 2px 9px; border-radius: 999px;
letter-spacing: .02em; background: rgb(from var(--acid) r g b / .12); color: var(--acid); }
.ctrls { position: absolute; top: -8px; right: -8px; display: none; gap: 2px; z-index: 2; }
.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; }
.ctrls button.rm:hover { background: var(--rose); border-color: var(--rose); }
.ctrls button.ed:hover { background: var(--blue); border-color: var(--blue); }
body.editing .icon .ctrls, body.editing .card .ctrls { display: flex; }
body.editing .icon, body.editing .card { cursor: grab; }
body.editing .icon.dragging, body.editing .card.dragging { opacity: .85; cursor: grabbing; }
.add-slot { display: none; align-items: center; justify-content: center;
border: 2px dashed var(--line2); border-radius: 14px;
color: var(--mut); font-size: 13.5px; cursor: pointer; }
/* Left-column + slot is positioned at the bottom of the free-drag area */
.column.left .add-slot { position: absolute; left: 10px; bottom: 10px;
width: 60px; height: 60px; border-radius: 14px; font-size: 11px; }
.column.right .add-slot { width: 100%; padding: 12px; border-radius: 14px; }
.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; }
.column.right .add-slot { display: none; }
body.editing .column.right .add-slot { display: 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="corner">
<button id="editBtn" type="button">Edit desktop</button>
</div>
<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 class="editbar">
<div class="group">
<span class="label">Icon size</span>
<div class="sz">
<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 layout">Reset</button>
</div>
</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="cards-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/">
<label>Description (shown in cards)</label><input id="mSub" placeholder="one-line description (optional)">
<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>Badge (for cards)</label>
<input id="mBadge" placeholder="e.g. Navigator">
</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;");
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>';
}
// 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: `<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: { 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>`,
};
// v4 schema. Same shape as v3 (sections.sm + sections.q) with per-item
// `sub` + `badge` fields on cards. Main.js's migrateCards preserves these
// fields across v3 and v4 (it treats items as opaque objects).
let state = null;
function defaultState() {
return {
version: 4,
iconSize: "large",
quickSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
sm: [
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's own address.", badge: "Navigator" },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" },
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names.", badge: "Registrar" },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host.", badge: "Code host" },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace.", badge: "App store" },
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web (in design).", badge: "Search" },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read decentralized names natively.", badge: "Resolver" },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from your Theseus vault.", badge: "Wallet" },
],
// Your-links icons are freely positioned (x,y per icon). The defaults
// put them in a 2-column grid near the top-left; drag anywhere to
// rearrange, like an OS desktop.
q: [
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch", x: 10, y: 10 },
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin", x: 110, y: 10 },
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos", x: 10, y: 110 },
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot", x: 110, y: 110 },
],
},
};
}
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 = "large";
if (!s.quickSize) s.quickSize = "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(".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;
}
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 column — freely positioned icon tiles. x/y are stored per icon
// and default to a tidy auto-layout if a legacy icon lacks them.
const q = state.sections.q || [];
const qGrid = $("qGrid");
qGrid.innerHTML = "";
$("qCount").textContent = `· ${q.length}`;
q.forEach((ic, i) => {
const size = ic.size || state.quickSize;
const el = document.createElement("a");
el.className = `icon size-${size}`;
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i;
// Icons without x/y (legacy data or just-added) get placed on an
// implicit grid near the top so they don't pile on each other.
const autoX = 10 + (i % 2) * 100, autoY = 10 + Math.floor(i / 2) * 100;
el.style.left = (ic.x != null ? ic.x : autoX) + "px";
el.style.top = (ic.y != null ? ic.y : autoY) + "px";
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
} 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>`;
wireIconClicks(el, "q", i);
el.addEventListener("pointerdown", (e) => startDrag(e, "q", i, el));
qGrid.appendChild(el);
});
const qAdd = document.createElement("button");
qAdd.className = "add-slot"; qAdd.textContent = "+"; qAdd.title = "Add to Your links";
qAdd.onclick = () => openModal("q", -1);
qGrid.appendChild(qAdd);
// RIGHT column — Silent Mode cards
const sm = state.sections.sm || [];
const smGrid = $("smGrid");
smGrid.innerHTML = "";
$("smCount").textContent = `· ${sm.length}`;
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 badge = ic.badge ? `<span class="card-badge">${esc(ic.badge)}</span>` : "";
el.innerHTML = `
<div class="ctrls">
<button class="ed" title="Edit">&#9998;</button>
<button class="rm" title="Remove">&times;</button>
</div>
<div class="card-ico">${iconArt(ic)}</div>
<div class="card-body">
<div class="card-title">${styledTitle(ic.title)}</div>
<div class="card-sub">${esc(ic.sub || ic.url)}</div>
</div>
${badge}`;
wireIconClicks(el, "sm", i);
el.addEventListener("pointerdown", (e) => startDrag(e, "sm", i, el));
smGrid.appendChild(el);
});
const smAdd = document.createElement("button");
smAdd.className = "add-slot"; smAdd.textContent = "+ Add card to Silent Mode";
smAdd.onclick = () => openModal("sm", -1);
smGrid.appendChild(smAdd);
}
function wireIconClicks(el, sec, i) {
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();
const url = state.sections[sec][i].url;
if (window.home && window.home.navigate) window.home.navigate(url);
else window.location.href = url;
});
}
// Drag model: LEFT column uses free (x,y) positioning like an OS desktop —
// snap to 20px, clamp inside the column. RIGHT column (cards) stays
// grid-ordered — drag inserts at the nearest Y-midpoint. Drop into the
// other column re-parents (becomes free or inserted into the grid as
// appropriate).
const SNAP = 20;
let drag = null;
function startDrag(e, sec, i, el) {
if (!document.body.classList.contains("editing")) return;
if (e.target.closest(".ctrls")) return;
e.preventDefault();
const rect = el.getBoundingClientRect();
drag = { sec, i, el, grabX: e.clientX - rect.left, grabY: e.clientY - rect.top };
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") || target.closest(".cards-grid"));
document.querySelectorAll(".drop-target").forEach((g) => { if (g !== over) g.classList.remove("drop-target"); });
if (over) over.classList.add("drop-target");
// Live move when dragging within the LEFT (free-positioning) column —
// gives OS-desktop feel. Cards stay still until drop.
if (drag.sec === "q" && over && over.id === "qGrid") {
const gridRect = over.getBoundingClientRect();
const w = drag.el.offsetWidth, h = drag.el.offsetHeight;
const nx = Math.max(0, Math.min(gridRect.width - w, e.clientX - gridRect.left - drag.grabX));
const ny = Math.max(0, Math.min(gridRect.height - h, e.clientY - gridRect.top - drag.grabY));
drag.el.style.left = Math.round(nx / SNAP) * SNAP + "px";
drag.el.style.top = Math.round(ny / SNAP) * SNAP + "px";
}
});
window.addEventListener("pointerup", (e) => {
if (!drag) return;
const el = drag.el;
const target = document.elementFromPoint(e.clientX, e.clientY);
const targetGrid = target && (target.closest(".section-grid") || target.closest(".cards-grid"));
const targetSec = targetGrid && targetGrid.dataset ? targetGrid.dataset.sec : null;
if (targetSec && (targetSec === "sm" || targetSec === "q")) {
const moved = state.sections[drag.sec][drag.i];
if (targetSec === "q") {
// LEFT drop — set the free x,y from where the pointer landed,
// clamped inside the grid area. From the SAME column this just
// keeps the x,y the live-move computed; from the OTHER column
// (RIGHT → LEFT) we move the item over and seed its x,y.
const gridRect = targetGrid.getBoundingClientRect();
const w = el.offsetWidth, h = el.offsetHeight;
const nx = Math.max(0, Math.min(gridRect.width - w, e.clientX - gridRect.left - drag.grabX));
const ny = Math.max(0, Math.min(gridRect.height - h, e.clientY - gridRect.top - drag.grabY));
moved.x = Math.round(nx / SNAP) * SNAP;
moved.y = Math.round(ny / SNAP) * SNAP;
if (targetSec !== drag.sec) {
state.sections[drag.sec].splice(drag.i, 1);
state.sections.q.push(moved);
}
} else {
// RIGHT drop — grid-ordered insert by Y midpoint of siblings.
const siblings = [...targetGrid.querySelectorAll(".card")];
let insertAt = siblings.length;
for (let k = 0; k < siblings.length; k++) {
const r = siblings[k].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { insertAt = k; break; }
}
const [m] = state.sections[drag.sec].splice(drag.i, 1);
if (targetSec === drag.sec && insertAt > drag.i) insertAt--;
// Moving a free-positioned icon into the right column drops its
// x,y — they'd just be noise in the card list.
delete m.x; delete m.y;
state.sections.sm.splice(insertAt, 0, m);
}
persist(); render();
}
document.querySelectorAll(".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: "", sub: "", badge: "", color: "#d6ff3d", letter: "" }
: state.sections[sec][i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSub").value = c.sub || "";
$("mBadge").value = c.badge || "";
$("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"); }
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,
sub: $("mSub").value.trim() || null,
badge: $("mBadge").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();
};
// 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 button").forEach((btn) => {
btn.onclick = () => {
state.quickSize = btn.dataset.size;
document.querySelectorAll(".editbar .sz 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 — routes through main.js (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);
});
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 button").forEach((b) => b.classList.toggle("on", b.dataset.size === state.quickSize));
}
// 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();
if (window.home && window.home.onCards) {
window.home.onCards((data) => {
const next = ensureShape(data);
if (next) { state = next; applyBg(); applySizeButtons(); render(); }
});
}
})();
</script>
</body>
</html>