theseus/home.html
Silent Mode 94ec382f20 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.
2026-10-12 01:41:17 +02:00

603 lines
35 KiB
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; --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>