theseus/home.html
Theseus Dev 9c29331e5b Theseus 0.3.131: Mail promoted to Sidebar quicklink, Nostr signer ships, panels free RAM when idle, Game.X in SM cards
Silent Mode Mail is no longer an extension. The 0.1.0 panel was a black
screen (iframe to mail.silentmode.st which sends X-Frame-Options: DENY;
the browser rendered the block page and still fired the iframe's load
event, so the 5-second "did it embed?" watchdog saw it as loaded and the
fallback never swapped in). The whole extension existed to put one "Open
Mail" button in a panel — that is exactly what a sidebar quicklink does,
without taking a panel slot or an Extensions row. So:

  - settings.quickLinks default list gains silentmail at the top
    (Silent Mode Mail → https://mail.silentmode.st/).
  - migrateAegisRename grew a mail retirement block: any existing
    extensions/mail/ folder on the user's box moves to
    addons-backups/mail-migrated-<stamp>/ on first boot of this
    Theseus, so the old extension vanishes from Settings instead of
    sitting there with a broken panel.
  - "mail" added to the first-party reserved-ids list so no community
    add-on can claim the id after retirement.

Nostr NIP-07 signer finally ships. The bundled-addons/nostr/ folder was
sitting untracked in the main checkout, so the detached-HEAD build
worktree 0.3.130 used never saw it (same reason build/, tor/, snapshots/
have to be hand-copied into the worktree). This build committed the
folder and copied it in. Puts window.nostr on https + localhost pages;
identity is Hermes's key (one npub for DMs and public notes). Per-origin
read/sign grants; kinds 0/3/5/10002 re-prompt every time with no
remember option.

Sidebar panel apps free their render tree after idle. closeLeftPanel
used to keep the loaded page warm forever — great for fast reopen, bad
for RAM when the user docks a heavy panel (Hermes, Pithos, Aegis) once
and never comes back. A 2-minute idle timer now blanks the panel to
about:blank after close; reopen within the window still reuses the
loaded page (cancelLeftPanelIdleUnload on open), reopen after the
window reloads fresh. LEFT_PANEL_IDLE_MS = 2 * 60 * 1000. Right-side
sidebar view (webapp tabs — Telegram, X, WhatsApp, AI chat) is longer-
lived by design and untouched here.

Startpage layout:
  - Game.X takes SilentMode.X's slot in the Silent Mode cards (stub
    description + a built-in gamepad brand mark until game.x goes
    live). SilentMode.X moves to the quick-links strip as the 5th
    slot. Still TODO: push the swap into the remote home-cards.json
    for existing profiles.
  - Light-theme palette flip under body.light. The @media
    (prefers-color-scheme: light) block only triggered on OS-level
    light preference; picking the "light" background preset left --ink
    white against a white page, so hero titles, card titles, slot
    labels and the ".bch" extension read as pale ghosts. Added a full
    body.light { --ink; --mut; --card; --line; ... } block plus
    overrides for the hero + card translucent panels (dark-glass →
    light-glass on light theme) and the ctx-menu / modal. Dark theme
    untouched.
2026-10-30 00:38:08 +02:00

1079 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head><meta charset="utf-8"><title>Theseus</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0e14'/%3E%3Cpath d='M32 6 L38 32 L26 32 Z' fill='%23e04e4e'/%3E%3Cpath d='M38 32 L32 58 L26 32 Z' fill='%23d6ff3d'/%3E%3Cpath d='M6 32 L32 27 L58 32 L32 37 Z' fill='%23d6ff3d' opacity='0.55'/%3E%3Ccircle cx='32' cy='32' r='3' fill='%230b0e14'/%3E%3C/svg%3E">
<style>
:root { color-scheme: light dark;
--bg1: #16202e; --bg2: #0b0e14;
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; --warm: #ffc75f; --rose: #f6768a; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); }
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
/* Hero (identity + search). Lives inside the right-hand .main-col,
centered within it. The outer .page grid handles the top padding so
the hero's own top padding is 0 — this keeps the sidebar's top edge
flush with the hero's top edge; the slots start at the same height
as the Theseus logo + search box. */
.hero { text-align: center; padding: 0 1rem; max-width: 820px; margin: 0 auto; }
.hero .hero-id { display: inline-flex; align-items: center; gap: 14px;
padding: 12px 24px 12px 14px; border-radius: 22px;
background: rgba(11,14,20,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.06);
margin-bottom: .8rem; }
.hero .mark { flex: none; width: 56px; height: 56px; line-height: 1; }
.hero .mark svg { width: 100%; height: 100%; display: block; }
.hero h1 { font-size: 2.2rem; margin: 0; letter-spacing: .5px; font-weight: 600; line-height: 1.05; }
.hero h1 .g { color: var(--acid); }
.hero .tag { color: var(--ink); margin: 0 0 1.4rem;
display: inline-block; padding: 6px 14px; border-radius: 999px;
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.hero form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
.hero form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
background: rgba(20,26,36,.72); color: var(--ink); font-size: 15px; outline: none;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.hero form input:focus { border-color: var(--blue); }
.hero form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hero .hint { color: var(--mut); font-size: 12.5px; margin-top: .8rem;
display: inline-block; padding: 4px 12px; border-radius: 999px;
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
/* "Theseus v0.3.X — What's new ↗" line under the hint. Muted so it does
not pull attention from the search box, but visible enough that a user
who just updated has a one-click path to the release notes. Dynamic:
version comes from the running app via home-preload. */
.hero .ver-line { margin: .6rem 0 0; font-size: 11.5px; }
.hero .ver-line a { color: var(--mut); text-decoration: none;
padding: 2px 10px; border-radius: 999px;
background: rgba(11,14,20,.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.hero .ver-line a:hover { color: var(--acid); }
/* Page-level 2-column layout: the sidebar is a left column that starts
at the top of the page (same top edge as the hero's title) and spans
the full height of the content, so the slot grid sits beside the
Theseus logo + search box instead of below them. The main column
stacks the hero (title + search + version link) on top of the Silent
Mode cards. Narrow viewports collapse to one column: sidebar first,
then hero, then cards. */
.page { display: grid; grid-template-columns: minmax(240px, 280px) minmax(380px, 1fr);
gap: 28px; max-width: 1680px; margin: 0 auto;
padding: 32px 32px 48px; align-items: start; }
.main-col { min-width: 0; display: flex; flex-direction: column; gap: 2rem; }
@media (max-width: 900px) { .page { grid-template-columns: 1fr; gap: 20px; padding: 14px; }
.main-col { gap: 1.4rem; } }
/* The RIGHT column keeps its subtle panel surface — the Silent Mode cards
read as a grouped collection and benefit from the soft tint. The LEFT
column opts out via the chromeless class below, which overrides this. */
.section { background: rgba(255,255,255,.025); border-radius: 16px;
padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03);
width: 100%; min-width: 0; }
body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); }
.section.chromeless { background: transparent; box-shadow: none; padding: 4px 0 0; }
body.light .section.chromeless { background: transparent; box-shadow: none; }
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
color: var(--mut); margin: 0 2px 14px; }
/* LEFT AREA — a fixed 3-column grid of slots. Each slot holds one link
tile; empty slots are invisible by default and reveal subtly on
container hover so the user can see where a dragged tile will land.
3 × 72 px tiles + 2 × 8 px gaps = 232 px — the sidebar column is sized
to that so the grid sits flush with the hero and doesn't float in
half-empty whitespace. */
/* The sidebar grid cell stretches to match the main column's height, so
the sidebar panel is as tall as the hero + Silent Mode cards combined.
The slots-list fills that full area: 3 columns at 1fr each (stretch to
sidebar width — the slot's inner tile stays 48 px and centers inside
the wider cell), and enough rows to cover the sidebar's vertical span
(row count computed in render() from the main-col height, so a taller
page gets more rows automatically). */
.sidebar { align-self: stretch; }
.slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px;
width: 100%; align-items: start; justify-content: stretch; }
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
cursor: pointer; user-select: none; text-decoration: none; color: inherit;
transition: transform .12s ease; }
.slot:hover { transform: translateY(-2px); }
.slot .tile { display: flex; align-items: center; justify-content: center;
border-radius: 12px; background: var(--iconBg, #0b0e14);
color: var(--iconFg, #e7eaf1); font-weight: 700; letter-spacing: -.02em;
box-shadow: 0 4px 12px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
overflow: hidden; }
.slot .tile svg, .slot .tile img { width: 76%; height: 76%; display: block; }
.slot .label { margin-top: 5px; font-size: 11px; line-height: 1.2; color: var(--ink);
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 1px 4px; border-radius: 4px; text-align: center; }
.slot.size-small .tile { width: 40px; height: 40px; font-size: 17px; border-radius: 10px; }
.slot.size-medium .tile { width: 48px; height: 48px; font-size: 20px; border-radius: 11px; }
.slot.size-large .tile { width: 60px; height: 60px; font-size: 24px; border-radius: 13px; }
.slot.size-small .label { font-size: 10.5px; }
.slot.dragging { opacity: .6; }
.slot.drag-before::before, .slot.drag-after::after {
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
}
.slot.drag-before::before { left: -8px; }
.slot.drag-after::after { right: -8px; }
/* Empty slots occupy a full 72×68 cell — same shape and position as a
filled slot — so the grid reads as one uniform surface and the user
can click any empty cell to add a link pinned to it. The 48 px tile
region matches where a filled slot's tile renders, but it carries no
"+" glyph and the dashed outline stays semi-transparent even when
revealed, so the empty cells never compete with the real icons for
attention. Fully invisible by default; container-hover shows the
faintest hint of where cells live; this-cell hover brightens enough
to confirm the click target; drop-hot (a dragged tile over it) is
the only time the outline reads strongly, because that is action
feedback the user needs to see. */
/* flex-direction: column + justify-content: flex-start pins the dashed
tile to the TOP of the 72×68 cell — same vertical position as a
filled slot's .tile (which sits above its .label). Without this the
dashed box was centered in the cell while the filled tile sat up at
the top, so the two columns looked misaligned vertically. */
.slot-empty { position: relative; display: flex; flex-direction: column;
align-items: center; justify-content: flex-start;
width: 100%; height: 68px; cursor: pointer; }
.slot-empty::before { content: "";
display: block; width: 48px; height: 48px; border-radius: 11px;
border: 2px dashed transparent; background: transparent;
transition: border-color .14s, background .14s; }
.slots-list:hover .slot-empty::before { border-color: rgb(from var(--line2) r g b / .18); }
.slot-empty:hover::before { border-color: rgb(from var(--acid) r g b / .35); background: rgb(from var(--acid) r g b / .03); }
.slot-empty.drop-hot::before { border-color: var(--acid); background: rgb(from var(--acid) r g b / .14); }
/* Silent Mode cards area — lives under the hero inside .main-col. The
.cards-wrap keeps the subtle panel surface (background, inset border,
rounded corners) so the cards read as a grouped collection; the
sidebar is chromeless so only this side has panel chrome. */
.cards-wrap { align-self: stretch; }
.cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; }
@media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } }
.card { display: flex; flex-direction: row; align-items: center; justify-content: flex-start;
gap: 14px; padding: 14px 16px; min-height: 92px;
background: rgba(20,26,36,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid var(--line); border-radius: 14px;
text-decoration: none; color: inherit; cursor: pointer; position: relative;
transition: transform .12s, background .12s, border-color .12s; }
.card:hover { background: rgba(20,26,36,.72); border-color: rgba(75,123,236,.4); transform: translateY(-1px); }
.card .card-left { flex: none; display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; }
.card .card-ico { flex: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--iconBg, transparent);
display: flex; align-items: center; justify-content: center;
color: var(--iconFg, #e7eaf1); font-weight: 700; font-size: 20px;
overflow: hidden; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
.card .card-ico svg, .card .card-ico img { width: 100%; height: 100%; display: block; }
.card .card-title { font-size: 13px; font-weight: 600; color: var(--ink); text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; line-height: 1.2; }
.card .card-title .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
.card .card-sub { flex: 1; min-width: 0; font-size: 12px; color: var(--mut); text-align: left; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden; }
.card:hover .card-sub { color: var(--ink); }
.card.dragging { opacity: .6; }
.card.drag-before::before, .card.drag-after::after {
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
}
.card.drag-before::before { left: -8px; }
.card.drag-after::after { right: -8px; }
/* Matches .card's horizontal shape so a "+ Add card" cell sits in the
grid as a peer of the real cards instead of a short dashed strip. */
.card-add { display: flex; align-items: center; justify-content: center;
gap: 14px; padding: 14px 16px; min-height: 92px;
border: 2px dashed var(--line2); border-radius: 14px;
background: rgba(20,26,36,.35); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
color: var(--mut); font-size: 13.5px; cursor: pointer;
transition: color .12s, border-color .12s, background .12s; }
.card-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); }
/* Context menus. One for the background (edit actions), one for images
(copy / edit / remove). Positioned at the pointer by JS. */
.ctx-menu { position: fixed; display: none; z-index: 1000; min-width: 200px;
background: rgba(20,26,36,.96); border: 1px solid var(--line2); border-radius: 10px;
padding: 6px; box-shadow: 0 16px 40px #000a; backdrop-filter: blur(10px); }
.ctx-menu.on { display: block; }
.ctx-menu button, .ctx-menu .submenu-trigger {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; text-align: left; background: transparent; border: 0;
color: var(--ink); padding: 8px 12px; border-radius: 6px; cursor: pointer;
font: inherit; font-size: 13px; }
.ctx-menu button:hover, .ctx-menu .submenu-trigger:hover { background: var(--card-hi); }
.ctx-menu button.danger { color: var(--rose); }
.ctx-menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
.ctx-menu .caret { color: var(--dim); font-size: 11px; }
.ctx-menu .value { color: var(--mut); font-size: 12px; }
/* Background chooser modal (opened from the context menu) */
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 1100;
align-items: center; justify-content: center; padding: 1rem; backdrop-filter: blur(4px); }
.modal-bg.on { display: flex; }
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 14px;
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
.modal input:focus, .modal select:focus { border-color: var(--blue); }
.modal .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
background: transparent; color: var(--mut); }
.modal button:hover { background: var(--card-hi); color: var(--ink); }
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.bg-preset-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 6px; }
.bg-preset-row button { height: 44px; border-radius: 8px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
.bg-preset-row button.on { outline: 2px solid var(--acid); outline-offset: 1px; }
.bg-color-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.bg-color-row input[type=color] { width: 44px; height: 32px; border: 1px solid var(--line2); border-radius: 6px; background: transparent; padding: 0; cursor: pointer; }
.bg-file { font-size: 12.5px; color: var(--mut); border: 1px dashed var(--line2); border-radius: 6px; padding: 6px 12px; cursor: pointer; }
.bg-file:hover { color: var(--acid); border-color: var(--acid); }
.toast { position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
background: rgba(20,26,36,.96); color: var(--ink); padding: 10px 18px; border-radius: 10px;
border: 1px solid var(--line2); font-size: 13px; opacity: 0; pointer-events: none;
transition: opacity .18s, transform .18s; z-index: 1200; }
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Light-theme palette flip. Applies when the user picks the "light"
background preset (body.light, set by applyBg()) OR when the OS
prefers light (the @media block below). Every element that reads from
--ink / --mut / --card / --line follows along; the handful of hero
and card elements that hard-code dark-glass rgba() values (built
for dark page body) have their own body.light overrides so the panel
flips to light-glass and the dark text inside stays readable. */
body.light {
--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;
}
body.light .hero .hero-id { background: rgba(255,255,255,.65);
box-shadow: 0 10px 30px rgba(0,0,0,.08), inset 0 0 0 1px rgba(0,0,0,.06); }
body.light .hero .tag { background: rgba(255,255,255,.55); color: var(--ink); }
body.light .hero form input { background: rgba(255,255,255,.78); color: var(--ink); border-color: var(--line2); }
body.light .hero .hint { background: rgba(255,255,255,.55); color: var(--mut); }
body.light .card { background: rgba(255,255,255,.6); border-color: var(--line); }
body.light .card:hover { background: rgba(255,255,255,.85); border-color: rgba(75,123,236,.4); }
body.light .card .card-title,
body.light .card .card-sub { color: var(--ink); }
body.light .card .card-sub { color: var(--mut); }
body.light .hero .ver-line a { background: rgba(255,255,255,.5); color: var(--mut); }
body.light .hero .ver-line a:hover { color: var(--acid); }
body.light .card-add { background: rgba(255,255,255,.35); color: var(--mut); border-color: var(--line2); }
body.light .card-add:hover { color: var(--acid); border-color: var(--acid); }
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px rgba(255,255,255,.65); }
body.light .slot .tile { box-shadow: 0 3px 10px rgba(0,0,0,.12), inset 0 0 0 1px rgba(0,0,0,.05); }
body.light .ctx-menu { background: #ffffff; }
body.light .ctx-menu button { color: var(--ink); }
body.light .ctx-menu button:hover { background: #f0f3fa; }
body.light .ctx-menu button.danger { color: #c43c2c; }
body.light .modal { background: #ffffff; color: var(--ink); }
body.light .modal input, body.light .modal select { background: #f8faff; color: var(--ink); border-color: var(--line2); }
body.light .modal button { color: var(--ink); border-color: var(--line2); }
body.light .modal button:hover { background: #f0f3fa; }
body.light .modal button.primary { background: var(--blue); color: #ffffff; border-color: var(--blue); }
body.light .toast { background: #ffffff; color: var(--ink); border-color: var(--line2); }
@media (prefers-color-scheme: light) {
:root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
--acid: #0AC18E; }
.slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.modal, .ctx-menu { background: #ffffff; }
}
</style>
</head>
<body>
<div class="page">
<aside class="section chromeless sidebar">
<div class="slots-list" id="slotsList"></div>
</aside>
<div class="main-col">
<div class="hero">
<div class="hero-id">
<div class="mark">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
</svg>
</div>
<h1>Theseus <span class="g">Navigator</span></h1>
</div>
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
<form id="searchForm">
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
<p class="ver-line" id="verLine" hidden><a id="verLink" href="https://silentmode.st/releases"></a></p>
</div>
<div class="section cards-wrap">
<div class="cards-grid" id="cardsGrid"></div>
</div>
</div>
</div>
<div class="ctx-menu" id="ctxBg">
<button data-action="change-bg">Change background&hellip;</button>
<button class="submenu-trigger" data-action="icon-size">
<span>Icon size</span><span class="value" id="ctxIconSizeVal">medium</span>
</button>
<hr>
<button data-action="add-link">Add link (left column)</button>
<button data-action="add-card">Add card (Silent Mode)</button>
<hr>
<button class="danger" data-action="reset">Reset to defaults</button>
</div>
<div class="ctx-menu" id="ctxItem">
<button data-action="open">Open</button>
<button data-action="open-new">Open in new tab</button>
<hr>
<button data-action="copy-image">Copy image</button>
<button data-action="copy-url">Copy link address</button>
<hr>
<button data-action="edit">Edit&hellip;</button>
<button class="danger" data-action="remove">Remove</button>
</div>
<div class="modal-bg" id="modal">
<div class="modal">
<h2 id="modalTitle">Add link</h2>
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
<label>Description (shown on cards)</label>
<input id="mSub" placeholder="one short line — optional">
<div class="row">
<div>
<label>Column</label>
<select id="mSection">
<option value="q">Left column (slots)</option>
<option value="sm">Silent Mode (3-column grid)</option>
</select>
</div>
<div>
<label>Icon color (fallback)</label>
<input id="mColor" type="color" value="#d6ff3d">
</div>
</div>
<label>Letter / emoji (optional)</label>
<input id="mLetter" placeholder="first letter is used by default" maxlength="4">
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div>
</div>
<div class="modal-bg" id="bgModal">
<div class="modal">
<h2>Background</h2>
<label>Preset</label>
<div class="bg-preset-row">
<button data-bg="dark" style="background:#0b0e14" title="Dark"></button>
<button data-bg="indigo" style="background:linear-gradient(135deg,#1e2a4a,#0b0e14)" title="Indigo"></button>
<button data-bg="forest" style="background:linear-gradient(135deg,#132821,#0b140e)" title="Forest"></button>
<button data-bg="rose" style="background:linear-gradient(135deg,#3a1830,#140b14)" title="Rose"></button>
<button data-bg="warm" style="background:linear-gradient(135deg,#3a2a18,#140e0b)" title="Warm"></button>
<button data-bg="light" style="background:linear-gradient(135deg,#dce6f2,#eef0f4)" title="Light"></button>
</div>
<label>Custom color</label>
<div class="bg-color-row">
<input id="bgColor" type="color" value="#0b0e14">
<label class="bg-file">Upload image<input id="bgImage" type="file" accept="image/*" style="display:none"></label>
</div>
<div class="btns"><button id="bgClose" class="primary" type="button">Done</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;");
function styledTitle(t) {
const m = String(t || "").match(/^(.+?)(\.[A-Za-z]{1,10})$/);
if (!m) return esc(t);
return esc(m[1]) + '<span class="tld">' + esc(m[2]) + '</span>';
}
function toast(msg) {
const t = $("toast"); t.textContent = msg; t.classList.add("on");
clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove("on"), 1800);
}
// Brand marks — inline SVG strings or { src } references to files bundled
// under home-brand/. Fall back to a letter tile when the icon has no brand.
const BRAND = {
theseus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/><path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/><path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity=".55"/><circle cx="32" cy="32" r="3" fill="#0b0e14"/></svg>`,
silentmode: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><rect x="10" y="22" width="26" height="20" rx="10" fill="none" stroke="#8b98a9" stroke-width="4"/><rect x="28" y="22" width="26" height="20" rx="10" fill="none" stroke="#d6ff3d" stroke-width="4"/></svg>`,
sirius: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 4 L36.4 27.6 L60 32 L36.4 36.4 L32 60 L27.6 36.4 L4 32 L27.6 27.6 Z" fill="#d6ff3d"/></svg>`,
hephaestus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 36 h36 l-4 6 h-28 Z" fill="#ffc75f"/><rect x="26" y="42" width="12" height="8" fill="#ffc75f"/><rect x="22" y="50" width="20" height="4" rx="1" fill="#ffc75f"/><rect x="34" y="12" width="4" height="18" rx="1" fill="#8b98a9" transform="rotate(25 36 21)"/><rect x="30" y="12" width="14" height="6" rx="1" fill="#e7eaf1" transform="rotate(25 37 15)"/></svg>`,
prometheus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 8 C 26 18, 20 22, 20 32 C 20 42, 26 50, 32 50 C 38 50, 44 42, 44 32 C 44 24, 40 22, 36 18 C 36 24, 34 26, 32 28 C 30 24, 34 16, 32 8 Z" fill="#ffc75f"/><path d="M32 20 C 28 26, 26 30, 26 36 C 26 42, 29 46, 32 46 C 35 46, 38 42, 38 36 C 38 32, 36 30, 34 26 C 34 30, 33 32, 32 32 C 30 28, 33 24, 32 20 Z" fill="#f6768a"/></svg>`,
hermes: { src: "home-brand/hermes-mark.svg" },
helios: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="12" fill="#ffc75f"/><g stroke="#ffc75f" stroke-width="3" stroke-linecap="round"><line x1="32" y1="8" x2="32" y2="14"/><line x1="32" y1="50" x2="32" y2="56"/><line x1="8" y1="32" x2="14" y2="32"/><line x1="50" y1="32" x2="56" y2="32"/><line x1="15" y1="15" x2="19" y2="19"/><line x1="45" y1="45" x2="49" y2="49"/><line x1="15" y1="49" x2="19" y2="45"/><line x1="45" y1="19" x2="49" y2="15"/></g></svg>`,
ariadne: { src: "home-brand/ariadne-mark.svg" },
pithos: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M22 18h20v4c0 2-2 3-2 5 8 3 14 10 14 20 0 10-9 17-22 17S10 57 10 47c0-10 6-17 14-20 0-2-2-3-2-5z" fill="#3ddc97"/><path d="M14 36h36M14 44h36" stroke="#0b1f18" stroke-width="2.5" stroke-linecap="round" opacity=".55"/></svg>`,
aegis: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="#0a0a0d" stroke="#D6FF3D" stroke-width="1.6" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.5" fill="none" stroke="#D6FF3D" stroke-width="1.4"/><circle cx="16" cy="16" r="1.6" fill="#D6FF3D"/></svg>`,
bergamot: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 50 C 14 28, 30 14, 54 14 C 54 36, 40 50, 18 50 C 20 42, 30 32, 44 28" fill="#9fd98f" stroke="#4fa66b" stroke-width="2" stroke-linejoin="round"/></svg>`,
game: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><rect x="8" y="20" width="48" height="26" rx="12" fill="#4b7bec"/><circle cx="22" cy="33" r="3.2" fill="#e7eaf1"/><circle cx="30" cy="33" r="3.2" fill="#e7eaf1"/><rect x="38" y="30.4" width="12" height="5.2" rx="1.2" fill="#e7eaf1"/><rect x="18" y="29" width="2.4" height="8" rx="1" fill="#0b0e14"/><rect x="14.8" y="32.2" width="8.8" height="2.4" rx="1" fill="#0b0e14"/></svg>`,
hellobch: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><text x="32" y="38" font-family="system-ui" font-weight="800" font-size="18" text-anchor="middle" fill="#8b98a9">hello</text><text x="32" y="54" font-family="system-ui" font-weight="800" font-size="13" text-anchor="middle" fill="#d6ff3d">.bch</text></svg>`,
siacoin: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="18" fill="#3ddc97"/><text x="32" y="40" font-family="system-ui" font-weight="800" font-size="22" text-anchor="middle" fill="#0b0e14">S</text></svg>`,
};
// v5 schema. Dropped the per-item x,y that v4 used for free-drag — left
// column is a vertical stack now, order within the array is the order on
// screen. Legacy x,y values are ignored by render() but preserved in
// storage (migrateCards treats items as opaque).
let state = null;
function defaultState() {
return {
version: 6,
iconSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
sm: [
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's home." },
{ title: "Game.X", url: "https://game.x/", brand: "game", sub: "Decentralized gaming on BCDN." },
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },
],
// Your-links icons live in fixed grid cells; drag reorders which
// icon is in which cell. Order in the array = position in the grid
// (row-first, 3 per row). SilentMode.X moved here from the sm cards
// so Game.X can take its slot on the right.
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" },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode" },
],
},
};
}
function ensureShape(s) {
if (!s || typeof s !== "object") return defaultState();
if (!s.sections || typeof s.sections !== "object") return defaultState();
if (!Array.isArray(s.sections.sm)) s.sections.sm = [];
if (!Array.isArray(s.sections.q)) s.sections.q = [];
if (!s.background) s.background = { kind: "preset", value: "dark" };
if (!s.iconSize) s.iconSize = "medium";
return s;
}
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
const BG_PRESETS = {
dark: "radial-gradient(1200px 600px at 50% -10%, #16202e, #0b0e14)",
indigo: "linear-gradient(135deg,#1e2a4a 0%, #0b0e14 100%)",
forest: "linear-gradient(135deg,#132821 0%, #0b140e 100%)",
rose: "linear-gradient(135deg,#3a1830 0%, #140b14 100%)",
warm: "linear-gradient(135deg,#3a2a18 0%, #140e0b 100%)",
light: "linear-gradient(135deg,#dce6f2 0%, #eef0f4 100%)",
};
function applyBg() {
const b = state.background || { kind: "preset", value: "dark" };
if (b.kind === "preset") document.body.style.background = BG_PRESETS[b.value] || BG_PRESETS.dark;
else if (b.kind === "color") document.body.style.background = b.value;
else if (b.kind === "image" && b.dataUrl) document.body.style.background = `#0b0e14 center/cover no-repeat url("${b.dataUrl}")`;
const isLight = (b.kind === "preset" && b.value === "light") || (b.kind === "color" && isLightColor(b.value));
document.body.classList.toggle("light", isLight);
document.querySelectorAll("#bgModal .bg-preset-row button").forEach((btn) => btn.classList.toggle("on", b.kind === "preset" && btn.dataset.bg === b.value));
$("bgColor").value = (b.kind === "color" ? b.value : "#0b0e14");
}
function isLightColor(hex) {
const h = String(hex || "").replace(/^#/, "");
if (h.length !== 6) return false;
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return (r * 0.299 + g * 0.587 + b * 0.114) > 170;
}
function iconLetter(icon) {
if (icon.letter) return icon.letter;
const t = (icon.title || "").trim();
return (t.match(/^[A-Za-z0-9]/) || ["?"])[0].toUpperCase();
}
function iconArt(icon) {
if (icon.brand && BRAND[icon.brand]) {
const b = BRAND[icon.brand];
if (typeof b === "string") return b;
if (b && b.src) return `<img src="${esc(b.src)}" alt="" draggable="false" style="width:100%;height:100%;object-fit:contain">`;
}
return esc(iconLetter(icon));
}
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
function render() {
// LEFT — fixed-cell grid of up to 9+ slots (3 wide × as many rows as
// needed). Each item in q[] may carry a `slot: number` to pin it to a
// specific cell; items without a slot flow top-to-bottom into the
// first unoccupied cells. Empty cells are drop targets, visible on
// hover of the grid. The "+" cell sits at the end so adding is always
// one click away.
const q = state.sections.q || [];
const slotCells = layoutSlots(q, minSlotsForFill());
const list = $("slotsList");
list.innerHTML = "";
slotCells.forEach((entry, cellIdx) => {
if (entry) {
const ic = entry.item, i = entry.i;
const el = document.createElement("a");
el.className = `slot size-${state.iconSize}`;
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; el.dataset.cell = cellIdx;
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
} else {
el.style.setProperty("--iconBg", "transparent");
el.style.setProperty("--iconFg", "#e7eaf1");
}
el.innerHTML = `<div class="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
wireSlot(el, i);
list.appendChild(el);
} else {
const empty = document.createElement("div");
empty.className = "slot-empty"; empty.dataset.cell = cellIdx;
empty.title = "Add link here";
wireEmptySlot(empty, cellIdx);
list.appendChild(empty);
}
});
// RIGHT — Silent Mode cards with sub-description under the title.
const sm = state.sections.sm || [];
const grid = $("cardsGrid");
grid.innerHTML = "";
sm.forEach((ic, i) => {
const el = document.createElement("a");
el.className = "card";
el.href = ic.url; el.dataset.sec = "sm"; el.dataset.i = i;
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
}
const sub = ic.sub ? `<div class="card-sub">${esc(ic.sub)}</div>` : "";
el.innerHTML = `<div class="card-left"><div class="card-ico">${iconArt(ic)}</div><div class="card-title">${styledTitle(ic.title)}</div></div>${sub}`;
wireCard(el, i);
grid.appendChild(el);
});
const add = document.createElement("button");
add.className = "card-add"; add.textContent = "+ Add card";
add.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openModal("sm", -1); };
add.oncontextmenu = (e) => e.preventDefault();
grid.appendChild(add);
$("ctxIconSizeVal").textContent = state.iconSize;
}
// Compute how many slot rows fit the vertical space the sidebar covers.
// Sidebar height = main-col height (align-self: stretch on the aside),
// so basing the row count on main-col keeps the slot grid filling the
// whole sidebar panel instead of ending halfway down. Falls back to 24
// (8 rows) when the layout hasn't been measured yet (first render).
function minSlotsForFill() {
const main = document.querySelector(".main-col");
const h = main ? main.getBoundingClientRect().height : 0;
const rowH = 78; // .slot-empty height (68) + .slots-list row gap (10)
const rows = h > 0 ? Math.max(6, Math.floor(h / rowH)) : 8;
return rows * 3;
}
// Lay out the q[] items into a fixed-slot grid: pinned items (with a
// `slot: number`) go into that cell; unpinned items flow into the first
// free cell, top-to-bottom. The grid always extends to at least one
// trailing empty row for drop space. minSlots defaults to 18 (3 × 6)
// when nothing better is passed; render() passes minSlotsForFill() so
// the grid actually covers the whole sidebar height.
function layoutSlots(items, minSlots = 18) {
const pinned = []; const unpinned = [];
items.forEach((ic, i) => {
const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null;
if (s !== null) pinned.push({ item: ic, i, slot: s });
else unpinned.push({ item: ic, i });
});
const maxPinned = pinned.reduce((m, p) => Math.max(m, p.slot), -1);
const used = pinned.length + unpinned.length;
let need = Math.max(minSlots, maxPinned + 1, used);
need = Math.ceil(need / 3) * 3;
need = Math.max(need, used + 1); // keep one trailing empty cell visible
need = Math.ceil(need / 3) * 3;
const cells = new Array(need).fill(null);
for (const p of pinned) {
if (p.slot < cells.length && !cells[p.slot]) cells[p.slot] = { item: p.item, i: p.i };
else unpinned.push({ item: p.item, i: p.i }); // collision: demote to unpinned
}
let u = 0;
for (let i = 0; i < cells.length && u < unpinned.length; i++) {
if (!cells[i]) { cells[i] = unpinned[u++]; }
}
return cells;
}
// Both sections are 2D grids: slots are fixed cells on the left (3 wide),
// cards are fixed cells on the right (3 wide). Drag reorders inside a
// section with a before/after indicator on the hover target; drop onto
// the other container's empty space re-parents (appends).
// Left-grid slots: drop onto a filled slot swaps the two slot assignments
// (so the user can rearrange visually without having to think about array
// order). Drop onto an empty slot is handled by wireEmptySlot.
function wireSlot(el, i) {
el.draggable = true;
el.addEventListener("click", (e) => {
e.preventDefault();
const url = state.sections.q[i].url;
if (window.home && window.home.navigate) window.home.navigate(url);
else window.location.href = url;
});
el.addEventListener("contextmenu", (e) => {
e.preventDefault(); e.stopPropagation();
openCtxItem(e.clientX, e.clientY, "q", i);
});
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "q", i }));
el.classList.add("dragging");
hideAllCtx();
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
document.querySelectorAll(".drop-hot,.drag-before,.drag-after")
.forEach((n) => n.classList.remove("drop-hot", "drag-before", "drag-after"));
});
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
el.classList.add("drag-before");
});
el.addEventListener("dragleave", () => el.classList.remove("drag-before"));
el.addEventListener("drop", (e) => {
e.preventDefault(); el.classList.remove("drag-before");
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
const targetCell = Number(el.dataset.cell);
if (payload.sec !== "q") { pinItemToSlot(payload.sec, payload.i, targetCell); return; }
if (payload.i === i) return;
swapSlots(payload.i, i, targetCell);
});
}
function wireCard(el, i) { wireGridItem(el, "sm", i); }
// Swap the pinned-slot positions of two q items. The dropped item takes
// over the target's cell; the target moves into whatever cell the dropped
// item was in. Unpinned items get pinned as part of the swap.
function swapSlots(fromI, toI, targetCell) {
const q = state.sections.q;
const src = q[fromI], dst = q[toI];
if (!src || !dst) return;
const prevSrcSlot = Number.isInteger(src.slot) ? src.slot : null;
src.slot = targetCell;
// The target item moves into the source's old slot; if source was
// unpinned, we need to give the target a slot too (its current flowed
// position) so it doesn't jump when the source leaves.
if (prevSrcSlot !== null) dst.slot = prevSrcSlot;
else {
// Find where dst currently sits in the laid-out grid, pre-swap.
const grid = layoutSlots(q);
const dstCell = grid.findIndex((e) => e && e.i === toI);
dst.slot = dstCell >= 0 ? dstCell : targetCell;
// After the swap, dst takes the old source cell. Re-compute.
const srcCell = grid.findIndex((e) => e && e.i === fromI);
if (srcCell >= 0) dst.slot = srcCell;
}
persist(); render();
}
// Empty cell in the left grid: accepts a dropped slot (from q or sm) and
// pins the item to this cell's index. Dropping an unpinned item here
// promotes it to pinned; dropping a pinned item just moves its pin.
function wireEmptySlot(el, cellIdx) {
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
el.classList.add("drop-hot");
});
el.addEventListener("dragleave", () => el.classList.remove("drop-hot"));
el.addEventListener("drop", (e) => {
e.preventDefault(); el.classList.remove("drop-hot");
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
pinItemToSlot(payload.sec, payload.i, cellIdx);
});
// Any empty cell doubles as the "add link" surface — click opens the
// link modal with this cell pre-pinned, so the user controls WHERE a
// new link lands (no dedicated + button needed).
el.addEventListener("click", (e) => {
e.preventDefault();
openModal("q", -1, cellIdx);
});
el.addEventListener("contextmenu", (e) => e.preventDefault());
}
// Place the dragged item at `cellIdx` in the q grid, pinning it.
// Cross-section (sm → q) moves the item first, then pins. Same-cell
// self-drop is a no-op.
function pinItemToSlot(fromSec, fromI, cellIdx) {
if (fromSec !== "q") {
const [m] = state.sections[fromSec].splice(fromI, 1);
m.slot = cellIdx;
state.sections.q.push(m);
} else {
const it = state.sections.q[fromI]; if (!it) return;
it.slot = cellIdx;
}
persist(); render();
}
function wireGridItem(el, sec, i) {
el.draggable = true;
el.addEventListener("click", (e) => {
e.preventDefault();
const url = state.sections[sec][i].url;
if (window.home && window.home.navigate) window.home.navigate(url);
else window.location.href = url;
});
el.addEventListener("contextmenu", (e) => {
e.preventDefault(); e.stopPropagation();
openCtxItem(e.clientX, e.clientY, sec, i);
});
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec, i }));
el.classList.add("dragging");
hideAllCtx();
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
document.querySelectorAll(".drag-before,.drag-after")
.forEach((n) => n.classList.remove("drag-before", "drag-after"));
});
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect();
const before = e.clientX < r.left + r.width / 2;
el.classList.toggle("drag-before", before);
el.classList.toggle("drag-after", !before);
});
el.addEventListener("dragleave", () => {
el.classList.remove("drag-before", "drag-after");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
const r = el.getBoundingClientRect();
const side = e.clientX < r.left + r.width / 2 ? -1 : 1;
let targetIdx = i + (side === 1 ? 1 : 0);
moveItem(payload.sec, payload.i, sec, targetIdx);
});
}
// Container-level drops handle cross-section drops onto empty space —
// the item appends to the target section's grid.
function wireContainer(container, sec) {
container.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; });
container.addEventListener("drop", (e) => {
if (e.target !== container) return;
e.preventDefault();
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
moveItem(payload.sec, payload.i, sec, state.sections[sec].length);
});
}
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
function moveItem(fromSec, fromI, toSec, toIdx) {
const [m] = state.sections[fromSec].splice(fromI, 1);
if (fromSec === toSec && toIdx > fromI) toIdx--;
toIdx = Math.max(0, Math.min(state.sections[toSec].length, toIdx));
state.sections[toSec].splice(toIdx, 0, m);
persist(); render();
}
// Context menus. Right-click on an empty area shows the background menu;
// right-click on an icon/card shows the item menu (copy image, edit, …).
let ctxTarget = null;
function hideAllCtx() { $("ctxBg").classList.remove("on"); $("ctxItem").classList.remove("on"); ctxTarget = null; }
function positionMenu(menu, x, y) {
menu.classList.add("on");
const r = menu.getBoundingClientRect();
const nx = Math.min(x, window.innerWidth - r.width - 8);
const ny = Math.min(y, window.innerHeight - r.height - 8);
menu.style.left = nx + "px"; menu.style.top = ny + "px";
}
function openCtxBg(x, y) {
hideAllCtx();
$("ctxIconSizeVal").textContent = state.iconSize;
positionMenu($("ctxBg"), x, y);
}
function openCtxItem(x, y, sec, i) {
hideAllCtx();
ctxTarget = { sec, i };
positionMenu($("ctxItem"), x, y);
}
document.addEventListener("contextmenu", (e) => {
// Default: background menu. Items override this with their own handler.
e.preventDefault();
openCtxBg(e.clientX, e.clientY);
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".ctx-menu")) hideAllCtx();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideAllCtx(); closeModal(); closeBgModal(); } });
window.addEventListener("blur", hideAllCtx);
window.addEventListener("scroll", hideAllCtx, true);
$("ctxBg").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action]"); if (!btn) return;
const a = btn.dataset.action;
hideAllCtx();
if (a === "change-bg") $("bgModal").classList.add("on");
else if (a === "icon-size") {
const next = state.iconSize === "small" ? "medium" : state.iconSize === "medium" ? "large" : "small";
state.iconSize = next; persist(); render();
toast("Icon size: " + next);
}
else if (a === "add-link") openModal("q", -1);
else if (a === "add-card") openModal("sm", -1);
else if (a === "reset") {
if (!confirm("Restore the default desktop? Your added links will be removed.")) return;
if (window.home && window.home.resetCards) await window.home.resetCards();
state = defaultState(); applyBg(); render(); persist();
}
});
$("ctxItem").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action]"); if (!btn) return;
if (!ctxTarget) { hideAllCtx(); return; }
const { sec, i } = ctxTarget;
const item = state.sections[sec][i]; if (!item) { hideAllCtx(); return; }
const a = btn.dataset.action;
hideAllCtx();
if (a === "open") {
if (window.home && window.home.navigate) window.home.navigate(item.url);
else window.location.href = item.url;
} else if (a === "open-new") {
if (window.home && window.home.openInNewTab) window.home.openInNewTab(item.url);
else window.open(item.url, "_blank");
} else if (a === "copy-image") {
await copyImage(item);
} else if (a === "copy-url") {
try { await navigator.clipboard.writeText(item.url); toast("Link copied"); }
catch { toast("Copy failed"); }
} else if (a === "edit") {
openModal(sec, i);
} else if (a === "remove") {
if (!confirm(`Remove "${item.title}"?`)) return;
state.sections[sec].splice(i, 1); persist(); render();
}
});
// Render the brand mark to a PNG blob and write it to the clipboard as an
// image. SVG strings get base64-encoded for the <img> source. {src: …}
// brands are loaded straight from the bundled file.
async function copyImage(item) {
try {
const b = (item.brand && BRAND[item.brand]) || null;
let srcUrl;
if (typeof b === "string") {
srcUrl = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(b)));
} else if (b && b.src) {
srcUrl = b.src;
} else {
// Letter tile — render a 128px square with the icon color + letter.
srcUrl = letterTileToDataURL(item);
}
const png = await imageToPngBlob(srcUrl, 256);
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
toast("Image copied");
} catch (err) {
console.error("copy image failed", err);
toast("Copy failed");
}
}
function imageToPngBlob(url, size) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("toBlob null")), "image/png");
};
img.onerror = (e) => reject(e);
img.src = url;
});
}
function letterTileToDataURL(item) {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d");
const bg = item.color || "#d6ff3d";
const fg = isLightColor(bg) ? "#0b0e14" : "#ffffff";
const r = 48;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.moveTo(r, 0); ctx.lineTo(size - r, 0); ctx.quadraticCurveTo(size, 0, size, r);
ctx.lineTo(size, size - r); ctx.quadraticCurveTo(size, size, size - r, size);
ctx.lineTo(r, size); ctx.quadraticCurveTo(0, size, 0, size - r);
ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = fg;
ctx.font = "bold 140px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(iconLetter(item), size / 2, size / 2 + 10);
return canvas.toDataURL("image/png");
}
// Add/edit modal — the same shape works for both left slots and right cards.
let editKey = null;
function openModal(sec, i, pinSlot) {
editKey = { sec, i, pinSlot: Number.isInteger(pinSlot) ? pinSlot : null };
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add link" : "Edit link";
const c = isNew ? { title: "", url: "", sub: "", color: "#d6ff3d", letter: "" }
: state.sections[sec][i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSub").value = c.sub || "";
$("mSection").value = sec;
$("mColor").value = c.color || "#d6ff3d";
$("mLetter").value = c.letter || "";
$("modal").classList.add("on");
setTimeout(() => $("mTitle").focus(), 20);
}
function closeModal() { $("modal").classList.remove("on"); }
$("mCancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
$("mSave").onclick = () => {
const title = $("mTitle").value.trim();
let url = $("mUrl").value.trim();
if (!title || !url) { alert("Title and URL are required."); return; }
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const next = {
title, url,
sub: $("mSub").value.trim() || null,
color: $("mColor").value,
letter: $("mLetter").value.trim() || null,
};
const toSec = $("mSection").value;
if (!editKey || editKey.i < 0) {
// Preserve the pinned cell index when the modal was opened from a
// specific empty slot — the new link lands exactly where the user
// clicked, not at the end.
if (editKey && editKey.pinSlot != null && toSec === "q") next.slot = editKey.pinSlot;
state.sections[toSec].push(next);
} else {
const prev = state.sections[editKey.sec][editKey.i];
const merged = { ...prev, ...next, brand: prev.brand || null };
if (toSec === editKey.sec) state.sections[toSec][editKey.i] = merged;
else { state.sections[editKey.sec].splice(editKey.i, 1); state.sections[toSec].push(merged); }
}
persist(); render(); closeModal();
};
// Background chooser modal
function closeBgModal() { $("bgModal").classList.remove("on"); }
$("bgClose").onclick = closeBgModal;
$("bgModal").addEventListener("click", (e) => { if (e.target.id === "bgModal") closeBgModal(); });
document.querySelectorAll("#bgModal .bg-preset-row button").forEach((btn) => {
btn.onclick = () => { state.background = { kind: "preset", value: btn.dataset.bg }; applyBg(); persist(); };
});
$("bgColor").oninput = () => { state.background = { kind: "color", value: $("bgColor").value }; applyBg(); persist(); };
$("bgImage").onchange = (e) => {
const f = e.target.files && e.target.files[0]; if (!f) return;
if (f.size > 2_000_000) { alert("Please pick an image under 2 MB."); return; }
const r = new FileReader();
r.onload = () => { state.background = { kind: "image", dataUrl: r.result }; applyBg(); persist(); };
r.readAsDataURL(f);
};
// Search form (empty cells themselves act as "add link" surfaces now;
// Select-all on first click of the search field so a click lands with any
// existing query ready to replace. Second click (already-focused) falls
// through to native cursor placement.
(() => {
const q = $("q"); let needSelect = false;
q.addEventListener("mousedown", (e) => { needSelect = (document.activeElement !== e.target); });
q.addEventListener("mouseup", (e) => { if (needSelect) { e.preventDefault(); e.target.select(); needSelect = false; } });
q.addEventListener("focus", (e) => { if (!needSelect) e.target.select(); });
})();
$("searchForm").addEventListener("submit", (e) => {
e.preventDefault();
const q = $("q").value.trim(); if (!q) return;
if (window.home && window.home.navigate) window.home.navigate(q);
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q);
});
if (window.home && window.home.getEngines) {
window.home.getEngines().then((r) => {
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name;
if (name) $("q").placeholder = "Search the web with " + name;
}).catch(() => {});
}
// Running-version footer: "Theseus v0.3.X — What's new ↗". Uses
// home.navigate so it opens release notes in this tab, same as any
// other link on the startpage. Hidden until the version resolves.
if (window.home && window.home.getVersion) {
window.home.getVersion().then((v) => {
if (!v) return;
const line = $("verLine"), a = $("verLink");
a.textContent = "Theseus v" + v + " — What’s new ↗";
a.addEventListener("click", (e) => {
e.preventDefault();
window.home.navigate("https://silentmode.st/releases");
});
line.hidden = false;
}).catch(() => {});
}
// Container-level drop handlers accept cross-column drops onto empty space.
wireContainer($("slotsList"), "q");
wireContainer($("cardsGrid"), "sm");
// Initial load. If the stored state has x,y on q items (v4 free-drag),
// we just ignore them — their order in the array is their order on screen.
(async () => {
let loaded = null;
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
state = ensureShape(loaded) || defaultState();
applyBg();
render();
// The first render runs before the main column has laid out, so the
// row count comes from the fallback (24). Re-render once the layout
// settles so the slot grid fills the sidebar vertically.
requestAnimationFrame(() => render());
if (window.home && window.home.onCards) {
window.home.onCards((data) => {
const next = ensureShape(data);
if (next) { state = next; applyBg(); render(); }
});
}
// Resize the slot grid when the viewport changes so new rows appear
// (or drop off) to match the sidebar's new height. ResizeObserver on
// the main column catches both window resizes and content-driven
// height changes (e.g. new cards being added).
const main = document.querySelector(".main-col");
if (main && window.ResizeObserver) {
let lastRows = -1, t = null;
const ro = new ResizeObserver(() => {
const rows = Math.floor(minSlotsForFill() / 3);
if (rows === lastRows) return;
lastRows = rows;
clearTimeout(t); t = setTimeout(() => render(), 80);
});
ro.observe(main);
}
})();
</script>
</body>
</html>