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

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

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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

721
home.html
View file

@ -8,241 +8,501 @@
--bg1: #16202e; --bg2: #0b0e14; --bg1: #16202e; --bg2: #0b0e14;
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22; --card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678; --ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; } --acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; --warm: #ffc75f; --rose: #f6768a; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; html, body { margin: 0; padding: 0; height: 100vh; overflow: hidden; font-family: system-ui, sans-serif; color: var(--ink); }
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); body {
color: var(--ink); display: flex; flex-direction: column; align-items: center; } display: flex; flex-direction: column;
.hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; } background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
.mark { font-size: 64px; line-height: 1; } }
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
h1 .g { color: var(--acid); }
.tag { color: var(--mut); margin: 0 0 1.6rem; }
form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
background: var(--card); color: var(--ink); font-size: 15px; outline: none; }
form input:focus { border-color: var(--blue); }
form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
/* Cards — larger, responsive. Grid autoscales to viewport width: /* Topbar: brand+search+edit. Three-column grid keeps the search centred
wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */ even when the brand and edit button expand to different widths. */
.cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; } .topbar { position: relative; z-index: 10; flex: none; padding: 24px 28px 12px;
.cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; } display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; }
.cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); } .search { display: flex; justify-content: center; }
.cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut); .search form { display: flex; gap: 8px; width: 100%; max-width: 560px; }
border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; } .search input { flex: 1; padding: 12px 20px; border-radius: 999px; border: 1px solid var(--line2);
.cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); } background: rgba(20,26,36,.72); color: var(--ink); font-size: 15px; outline: none;
.cards-hdr .tools button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); } backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } .search input:focus { border-color: var(--blue); }
@media (min-width: 900px) { .grid { gap: 18px; } } .search button { padding: 12px 22px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
@media (min-width: 1200px) { .grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } } .brandbar { display: flex; align-items: center; gap: 12px; justify-content: flex-end; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .cards-wrap { padding: 0 .8rem; } } .brandbar .brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-size: 15px; white-space: nowrap; }
.card { display: block; text-decoration: none; color: inherit; background: var(--card); border: 1px solid var(--line); .brandbar .brand .mark { width: 24px; height: 24px; display: inline-block; }
border-radius: 14px; padding: 20px 20px 18px; transition: transform .15s, background .15s, border-color .15s; .brandbar .brand .g { color: var(--acid); font-weight: 600; }
position: relative; cursor: pointer; } .brandbar button { background: rgba(20,26,36,.6); border: 1px solid var(--line2); color: var(--mut);
.card:hover { border-color: rgba(75,123,236,.4); background: var(--card-hi); transform: translateY(-2px); } border-radius: 8px; padding: 8px 14px; font-size: 12.5px; cursor: pointer; backdrop-filter: blur(6px); }
.card .n { font-weight: 600; color: var(--ink); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .brandbar button:hover { background: var(--card-hi); color: var(--ink); }
/* TLD suffix (.x, .bch, ...) rendered in the Silent Mode acid so the brand .brandbar button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
accent reads instantly across the grid. Subtle glow reinforces it. */
.card .n .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
.card .d { color: var(--mut); font-size: 13px; margin-top: 6px; line-height: 1.4; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.card:hover .d { color: var(--ink); }
.card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 8px; }
.badge { display: inline-block; font-size: 10.5px; padding: 2px 9px; border-radius: 999px; letter-spacing: .02em; }
/* Provenance-typed badges (which chain/storage backed the site). */
.b-on-chain { background: rgb(from var(--acid) r g b / .14); color: var(--acid); }
.b-sia { background: rgba(179,157,219,.14); color: var(--sia); }
.b-server { background: rgba(79,209,165,.14); color: var(--srv); }
/* Product-role badges — same acid family with per-role hue shifts.
Kept muted so the badge is legible but doesn't compete with the title. */
.b-infrastructure { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
.b-navigator { background: rgba(75,123,236,.14); color: #7ea3ff; }
.b-registrar { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
.b-code-host { background: rgba(179,157,219,.14); color: var(--sia); }
.b-app-store { background: rgba(79,209,165,.14); color: var(--srv); }
.b-search { background: rgba(255,199,95,.14); color: #ffc75f; }
.b-messaging { background: rgba(246,118,138,.14); color: #f6768a; }
.b-default { background: rgba(139,152,169,.14); color: var(--mut); }
/* Edit affordances — hidden until Edit mode toggled */ /* Edit toolbar — floating pill, shown only while editing. */
.card .edit-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; } .editbar { position: absolute; top: 76px; left: 50%; transform: translateX(-50%); z-index: 9;
.card .edit-actions button { border: 1px solid var(--line2); background: rgba(0,0,0,.35); color: var(--mut); display: none; gap: 14px; align-items: center; padding: 10px 16px;
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 0; } background: rgba(20,26,36,.9); border: 1px solid var(--line2); border-radius: 14px;
.card .edit-actions button:hover { color: var(--ink); background: rgba(0,0,0,.55); } backdrop-filter: blur(10px); box-shadow: 0 10px 30px #0008; max-width: calc(100vw - 48px); flex-wrap: wrap; }
.card .edit-actions button.rm:hover { color: #f6768a; border-color: rgba(246,118,138,.5); } body.editing .editbar { display: flex; }
body.editing .card .edit-actions { display: flex; } .editbar .group { display: flex; align-items: center; gap: 8px; }
body.editing .card { cursor: default; } .editbar .label { font-size: 11px; color: var(--mut); letter-spacing: .08em; text-transform: uppercase; }
body.editing .card:hover { transform: none; } .editbar .sz { display: inline-flex; border: 1px solid var(--line2); border-radius: 999px; overflow: hidden; }
.add-card { display: none; align-items: center; justify-content: center; .editbar .sz button { background: transparent; border: 0; color: var(--mut); padding: 6px 10px; font-size: 12px; cursor: pointer; }
background: transparent; border: 2px dashed var(--line2); border-radius: 14px; .editbar .sz button.on { background: var(--acid); color: #0b0e14; font-weight: 600; }
color: var(--mut); font-size: 14px; cursor: pointer; padding: 20px; min-height: 100px; } .editbar .bg { display: inline-flex; gap: 6px; }
.add-card:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); } .editbar .bg button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
body.editing .add-card { display: flex; } .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); }
/* Modal for add/edit */ /* 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; .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; } align-items: center; justify-content: center; padding: 1rem; backdrop-filter: blur(4px); }
.modal-bg.on { display: flex; } .modal-bg.on { display: flex; }
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 12px; .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; } 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 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 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); .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; } 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 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 .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); .modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
background: transparent; color: var(--mut); } background: transparent; color: var(--mut); }
.modal button:hover { background: var(--card-hi); color: var(--ink); } .modal button:hover { background: var(--card-hi); color: var(--ink); }
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); } .modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.modal button.primary:hover { background: #5a89f5; }
footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; }
footer b { color: #6b7688; }
/* Light theme */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { --bg1: #dce6f2; --bg2: #eef0f4; :root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14); --card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2; --ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
/* Darker acid for light backgrounds — ~5.5:1 on white. */
--acid: #0AC18E; } --acid: #0AC18E; }
h1 .g { color: var(--acid); } .icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.modal { background: #ffffff; } .modal { background: #ffffff; }
} }
</style></head> </style></head>
<body> <body>
<div class="hero"> <div class="topbar">
<div class="mark">⛓️</div> <div></div>
<h1>Theseus <span class="g">Navigator</span></h1> <div class="search">
<p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p> <form id="searchForm">
<form id="searchForm"> <input id="q" placeholder="Search the web" autofocus spellcheck="false">
<input id="q" name="q" placeholder="Search the web" autofocus spellcheck="false"> <button type="submit">Search</button>
<button type="submit">Search</button> </form>
</form> </div>
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p> <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>
<div class="cards-wrap"> <div class="editbar">
<div class="cards-hdr"> <div class="group">
<span class="title">Quick links</span> <span class="label">Family size</span>
<div class="tools"> <div class="sz" data-which="iconSize">
<button id="editBtn" type="button">Edit</button> <button data-size="small">S</button>
<button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button> <button data-size="medium">M</button>
<button data-size="large" class="on">L</button>
<button data-size="xl">XL</button>
</div> </div>
</div> </div>
<div class="grid" id="grid"></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>
<div class="modal-bg" id="modal"> <div class="modal-bg" id="modal">
<div class="modal"> <div class="modal">
<h2 id="modalTitle">Add card</h2> <h2 id="modalTitle">Add link</h2>
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch"> <label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
<label>URL</label><input id="mUrl" placeholder="https://example.com/"> <label>URL</label><input id="mUrl" placeholder="https://example.com/">
<label>Subtitle (optional)</label><input id="mSub" placeholder="one-line description"> <div class="row">
<label>Badge</label> <div>
<select id="mBadge"> <label>Column</label>
<option value="">— none —</option> <select id="mSection">
<option value="on-chain">on-chain</option> <option value="q">Your links (left)</option>
<option value="Sia">Sia</option> <option value="sm">Silent Mode (right)</option>
<option value="server">server</option> </select>
<option value="default">(custom)</option> </div>
</select> <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 class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div> </div>
</div> </div>
<footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;"); const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;");
let cards = []; let editIdx = -1;
// Search form: submit → navigate the tab via IPC (main.js applies the // ---- Brand marks ----
// user's default search engine + regional hints). // Keyed by icon.brand slug; value is an inline SVG string or an
$("searchForm").addEventListener("submit", (e) => { // { src: "<relative-url>" } descriptor pointing at a bundled file.
e.preventDefault(); // Entries without a brand fall back to a colored tile with a letter.
const q = $("q").value.trim(); if (!q) return; const BRAND = {
if (window.home && window.home.navigate) window.home.navigate(q); 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>`,
// Fallback if the preload didn't bind (shouldn't happen). Startpage 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>`,
// over DuckDuckGo since that's Theseus's own default engine now. 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>`,
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q); 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>`,
// Populate placeholder with the user's current default engine name so 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>`,
// "Search the web with <engine>" reflects reality. Silently no-ops if 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>`,
// the preload or IPC isn't available (dev-open of home.html in a plain // Ariadne — real brand mark in ancient-Greek-pottery palette (terracotta
// Chromium tab). Also refreshes on onEngines events would be nice but // rings around the cream figure, derived from site-ariadne-x/assets/favicon.svg).
// main only broadcasts `engines` to chrome — home only queries at load, // Bundled under home-brand/ariadne-pottery.svg and referenced relatively.
// which is fine because switching engines is a settings action that ariadne: { src: "home-brand/ariadne-pottery.svg" },
// usually implies a reload of the tab shortly after. 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>`,
if (window.home && window.home.getEngines) { // Aegis — straight from bundled-addons/aegis/addon.json's data: URI,
window.home.getEngines().then((r) => { // decoded as inline SVG so the home tile matches the sidebar icon.
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name; 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>`,
if (name) $("q").placeholder = "Search the web with " + name; 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>`,
}).catch(() => {}); 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 badgeClass(b) { function ensureShape(s) {
// Normalize badge label into a CSS class (spaces + case → dashes / lower). // Keep whatever the user has, but guarantee the keys the render loop
if (!b) return "b-default"; // reads — tolerate a half-migrated or partial object without crashing.
return "b-" + String(b).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); 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;
} }
// Split a domain-style title so ".X" (or ".bch") can render in the acid brand function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
// color while the rest stays ink. Preserves capitals in the base label.
function styledTitle(t) { // ---- background ----
const m = String(t).match(/^(.+?)(\.[A-Za-z]{1,10})$/); const BG_PRESETS = {
if (!m) return esc(t); dark: "radial-gradient(1200px 600px at 50% -10%, #16202e, #0b0e14)",
return esc(m[1]) + '<span class="tld">' + esc(m[2]) + '</span>'; indigo: "linear-gradient(135deg,#1e2a4a 0%, #0b0e14 100%)",
} forest: "linear-gradient(135deg,#132821 0%, #0b140e 100%)",
function render() { rose: "linear-gradient(135deg,#3a1830 0%, #140b14 100%)",
const g = $("grid"); warm: "linear-gradient(135deg,#3a2a18 0%, #140e0b 100%)",
g.innerHTML = cards.map((c, i) => { light: "linear-gradient(135deg,#dce6f2 0%, #eef0f4 100%)",
const badge = c.badge ? `<span class="badge ${badgeClass(c.badge)}">${esc(c.badge)}</span>` : ""; };
return `<div class="card" data-i="${i}"> function applyBg() {
<div class="edit-actions"> const b = state.background || { kind: "preset", value: "dark" };
<button class="ed" title="Edit">✎</button> if (b.kind === "preset") document.body.style.background = BG_PRESETS[b.value] || BG_PRESETS.dark;
<button class="rm" title="Remove">✕</button> else if (b.kind === "color") document.body.style.background = b.value;
</div> else if (b.kind === "image" && b.dataUrl) document.body.style.background = `#0b0e14 center/cover no-repeat url("${b.dataUrl}")`;
<div class="n">${styledTitle(c.title)}</div> const isLight = (b.kind === "preset" && b.value === "light") || (b.kind === "color" && isLightColor(b.value));
<div class="d">${esc(c.sub || c.url)}</div> document.body.classList.toggle("light", isLight);
<div class="row">${badge}</div> document.querySelectorAll(".editbar .bg button").forEach((btn) => {
</div>`; btn.classList.toggle("on", b.kind === "preset" && btn.dataset.bg === b.value);
}).join("") + `<div class="add-card" id="addBtn">+ Add card</div>`;
// Wire clicks: normal-mode = navigate, edit-mode = per-action buttons.
g.querySelectorAll(".card").forEach((el) => {
const i = Number(el.dataset.i);
el.onclick = (e) => {
if (document.body.classList.contains("editing")) {
if (e.target.classList.contains("rm")) return removeCard(i);
if (e.target.classList.contains("ed")) return openModal(i);
return; // click on card body in edit mode = no-op
}
// normal mode: navigate
if (window.home && window.home.navigate) window.home.navigate(cards[i].url);
else window.location.href = cards[i].url;
};
}); });
$("addBtn").onclick = () => openModal(-1); $("bgColor").value = (b.kind === "color" ? b.value : "#0b0e14");
} }
function removeCard(i) { function isLightColor(hex) {
if (!confirm(`Remove "${cards[i].title}" from Quick links?`)) return; const h = String(hex || "").replace(/^#/, "");
cards.splice(i, 1); persist(); render(); 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 openModal(i) {
editIdx = i; // ---- 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; const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add card" : "Edit card"; $("modalTitle").textContent = isNew ? "Add link" : "Edit link";
const c = isNew ? { title: "", url: "", sub: "", badge: "" } : cards[i]; const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "", size: "" }
$("mTitle").value = c.title || ""; : state.sections[sec][i];
$("mUrl").value = c.url || ""; $("mTitle").value = c.title || "";
$("mSub").value = c.sub || ""; $("mUrl").value = c.url || "";
$("mBadge").value = c.badge || ""; $("mSection").value = sec;
$("mSize").value = c.size || "";
$("mColor").value = c.color || "#d6ff3d";
$("mLetter").value = c.letter || "";
$("modal").classList.add("on"); $("modal").classList.add("on");
setTimeout(() => $("mTitle").focus(), 20); setTimeout(() => $("mTitle").focus(), 20);
} }
function closeModal() { $("modal").classList.remove("on"); } 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; $("mCancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); }); $("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
@ -250,40 +510,91 @@
const title = $("mTitle").value.trim(); const title = $("mTitle").value.trim();
let url = $("mUrl").value.trim(); let url = $("mUrl").value.trim();
if (!title || !url) { alert("Title and URL are required."); return; } if (!title || !url) { alert("Title and URL are required."); return; }
// Normalize URL: allow bare hosts like "example.com" (add https://).
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url; if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const card = { title, url, sub: $("mSub").value.trim(), badge: $("mBadge").value }; const next = { title, url, color: $("mColor").value, letter: $("mLetter").value.trim() || null, size: $("mSize").value || null };
if (editIdx < 0) cards.push(card); else cards[editIdx] = card; 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(); persist(); render(); closeModal();
}; };
// ---- edit toggle + toolbar ----
$("editBtn").onclick = () => { $("editBtn").onclick = () => {
const editing = document.body.classList.toggle("editing"); const editing = document.body.classList.toggle("editing");
$("editBtn").classList.toggle("on", editing); $("editBtn").classList.toggle("on", editing);
$("editBtn").textContent = editing ? "Done" : "Edit"; $("editBtn").textContent = editing ? "Done" : "Edit desktop";
$("resetBtn").style.display = editing ? "" : "none";
}; };
$("addBtn").onclick = () => openModal("q", -1);
$("resetBtn").onclick = async () => { $("resetBtn").onclick = async () => {
if (!confirm("Restore the default Quick links? Your custom entries will be lost.")) return; if (!confirm("Restore the default desktop? Your added links will be removed.")) return;
if (window.home) await window.home.resetCards(); if (window.home && window.home.resetCards) await window.home.resetCards();
cards = window.home ? (await window.home.getCards()) : []; state = defaultState();
render(); applyBg(); applySizeButtons(); render();
persist();
}; };
function persist() { if (window.home && window.home.setCards) window.home.setCards(cards); } document.querySelectorAll(".editbar .sz").forEach((group) => {
// Initial load — via IPC when available, otherwise a hardcoded fallback so const which = group.dataset.which; // iconSize | quickSize
// opening home.html directly (e.g. from disk without Electron) still shows group.querySelectorAll("button").forEach((btn) => {
// something instead of a blank grid. 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 () => { (async () => {
if (window.home && window.home.getCards) cards = await window.home.getCards(); let loaded = null;
if (!cards || !cards.length) cards = [ try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
{ title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" }, state = ensureShape(loaded) || defaultState();
{ title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" }, applyBg(); applySizeButtons(); render();
]; // Main may push a fresher state (remote-cache refresh succeeded) via onCards.
render();
// If main pushes a refreshed list from the remote URL, adopt it — but
// only when the user hasn't customised (main gates this on its side too).
if (window.home && window.home.onCards) { if (window.home && window.home.onCards) {
window.home.onCards((list) => { window.home.onCards((data) => {
if (Array.isArray(list) && list.length) { cards = list; render(); } const next = ensureShape(data);
if (next) { state = next; applyBg(); applySizeButtons(); render(); }
}); });
} }
})(); })();

151
main.js
View file

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

View file

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