aboutsummaryrefslogtreecommitdiffstats
path: root/startpage
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /startpage
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to 'startpage')
-rw-r--r--startpage/.htaccess8
-rw-r--r--startpage/README.md49
-rw-r--r--startpage/amazon.pngbin0 -> 40952 bytes
-rw-r--r--startpage/basedlii.pngbin0 -> 42930 bytes
-rw-r--r--startpage/belleville-old.pngbin0 -> 99209 bytes
-rw-r--r--startpage/belleville.pngbin0 -> 62329 bytes
-rw-r--r--startpage/buyvm.pngbin0 -> 4693 bytes
-rw-r--r--startpage/cards.txt189
-rw-r--r--startpage/ctvnews.pngbin0 -> 9930 bytes
-rw-r--r--startpage/cytube.pngbin0 -> 9027 bytes
-rw-r--r--startpage/ebay.pngbin0 -> 68914 bytes
-rw-r--r--startpage/gitlogo.pngbin0 -> 91512 bytes
-rw-r--r--startpage/heyuri.pngbin0 -> 28769 bytes
-rw-r--r--startpage/index.php454
-rw-r--r--startpage/kodiaks.pngbin0 -> 40401 bytes
-rw-r--r--startpage/lain.pngbin0 -> 118394 bytes
-rw-r--r--startpage/liivid.pngbin0 -> 19502 bytes
-rw-r--r--startpage/nav_links.txt108
-rw-r--r--startpage/nav_links_xhtml.txt108
-rw-r--r--startpage/news.pngbin0 -> 13477 bytes
-rw-r--r--startpage/newspaper.pngbin0 -> 85234 bytes
-rw-r--r--startpage/noteback.gifbin0 -> 89 bytes
-rw-r--r--startpage/old-youtube-icon-75.pngbin0 -> 78193 bytes
-rw-r--r--startpage/openbsd_version.txt1
-rw-r--r--startpage/render_cards.php76
-rw-r--r--startpage/roundcube.pngbin0 -> 21377 bytes
-rw-r--r--startpage/shoppingcart.pngbin0 -> 49558 bytes
-rw-r--r--startpage/sm_logo.pngbin0 -> 6879 bytes
-rw-r--r--startpage/somethingawful.pngbin0 -> 36511 bytes
-rw-r--r--startpage/sonemic.pngbin0 -> 26546 bytes
-rw-r--r--startpage/sonymp3.pngbin0 -> 24258 bytes
-rw-r--r--startpage/startpage-icon.pngbin0 -> 26234 bytes
-rw-r--r--startpage/startpage.css1892
-rw-r--r--startpage/startpage.js824
-rw-r--r--startpage/tf2.pngbin0 -> 35302 bytes
-rw-r--r--startpage/tilde.pngbin0 -> 1909 bytes
-rw-r--r--startpage/videogame.jpgbin0 -> 52996 bytes
-rw-r--r--startpage/videogame.pngbin0 -> 26775 bytes
-rw-r--r--startpage/wallpaper.jpgbin0 -> 215198 bytes
39 files changed, 3709 insertions, 0 deletions
diff --git a/startpage/.htaccess b/startpage/.htaccess
new file mode 100644
index 0000000..3f07952
--- /dev/null
+++ b/startpage/.htaccess
@@ -0,0 +1,8 @@
+<IfModule mod_headers.c>
+ <Files "index.html">
+ Header set X-Content-Type-Options "nosniff"
+ Header set Referrer-Policy "strict-origin-when-cross-origin"
+ Header set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
+ Header set Content-Security-Policy "default-src 'self'; base-uri 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://sirocco.accuweather.com; form-action 'self' https://www.google.com https://www.google.ca; object-src 'none'"
+ </Files>
+</IfModule>
diff --git a/startpage/README.md b/startpage/README.md
new file mode 100644
index 0000000..7ab37c8
--- /dev/null
+++ b/startpage/README.md
@@ -0,0 +1,49 @@
+# startpage/
+
+Retro terminal personal startpage for sillylaird.ca.
+
+## Usage
+- Open https://www.sillylaird.ca/startpage/
+- Use the engine picker to switch search (persisted).
+- Type in "Filter cards & links" to live-filter the grid.
+- Keyboard: `/` focuses filter; `?` shows help overlay; `Esc` clears.
+- Cards powered by `cards.txt` (edit via /admin.php?section=startpage or directly).
+- Last.fm now playing pulls from site API.
+
+## Data format (cards.txt)
+```
+# Category Name | icon.png | alt
+Label | https://url
+Label2 | https://url2
+
+# Next Category | ...
+```
+
+- One header line starts with `# `
+- Following lines until blank or next header are links `Label | url`
+- Supported schemes: http/https/gemini/gopher
+- Icons must be local filenames in this dir (no ..)
+
+## Editing
+Preferred: /admin.php (authenticated).
+
+## OpenBSD boot banner version
+The boot overlay shows `OpenBSD X.Y`. Source of truth:
+
+1. `openbsd_version.txt` — written by `../tools/update_openbsd_version.sh` (daily cron scrapes https://www.openbsd.org/)
+2. JS date formula fallback if the cache is missing (anchor 7.9 = May 2026)
+
+`index.php` injects `window.OPENBSD_RELEASE` from the cache file.
+
+## Improvements (current)
+- Removed vestigial age modal.
+- Added live card filter + keyboard (`/`, `?`, Esc).
+- Persisted search engine choice.
+- CSS grid cards.
+- Subtle wallpaper texture.
+- Global theme toggle supported.
+- OpenBSD version auto-updated via cron.
+
+Legacy: nav_links.txt (unused flat list).
+
+See index.php, startpage.css, startpage.js, render_cards.php for implementation.
diff --git a/startpage/amazon.png b/startpage/amazon.png
new file mode 100644
index 0000000..9be01b5
--- /dev/null
+++ b/startpage/amazon.png
Binary files differ
diff --git a/startpage/basedlii.png b/startpage/basedlii.png
new file mode 100644
index 0000000..db0451b
--- /dev/null
+++ b/startpage/basedlii.png
Binary files differ
diff --git a/startpage/belleville-old.png b/startpage/belleville-old.png
new file mode 100644
index 0000000..3d2eab0
--- /dev/null
+++ b/startpage/belleville-old.png
Binary files differ
diff --git a/startpage/belleville.png b/startpage/belleville.png
new file mode 100644
index 0000000..545f1c2
--- /dev/null
+++ b/startpage/belleville.png
Binary files differ
diff --git a/startpage/buyvm.png b/startpage/buyvm.png
new file mode 100644
index 0000000..3e18331
--- /dev/null
+++ b/startpage/buyvm.png
Binary files differ
diff --git a/startpage/cards.txt b/startpage/cards.txt
new file mode 100644
index 0000000..35d7d39
--- /dev/null
+++ b/startpage/cards.txt
@@ -0,0 +1,189 @@
+# Blog | newspaper.png | Writing
+Blog | https://blog.sillylaird.ca/
+DJLain | https://djlain.com/
+RisingThumb | https://risingthumb.xyz/
+Stallman | https://stallman.org/
+Joel on Software | https://www.joelonsoftware.com/
+Coding Horror | https://blog.codinghorror.com/
+Simon Willison | https://simonwillison.net/
+Brendan Gregg | https://www.brendangregg.com/blog/
+LWN | https://lwn.net/
+Dan Luu | https://danluu.com/
+Julia Evans | https://jvns.ca/
+Rachel by the Bay | https://rachelbythebay.com/w/
+Drew DeVault | https://drewdevault.com/
+Schneier | https://www.schneier.com/
+Krebs on Security | https://krebsonsecurity.com/
+Phoronix | https://www.phoronix.com/
+Undeadly | https://undeadly.org/
+
+# Multimedia | basedlii.png | Visual
+YouTube | https://www.youtube.com/
+Rumble | https://rumble.com/
+BitChute | https://www.bitchute.com/
+NicoVideo | https://www.nicovideo.jp/
+BiliBili | https://www.bilibili.com/
+Youku | https://www.youku.com/ku/webhome
+Basedlii.space | https://basedlii.space/
+PeerTube | https://peertube.1312.media/videos/browse?sort=-trending&scope=local&c=true&s=3
+TikTok | https://www.tiktok.com/
+Douyin | https://www.douyin.com/
+Instagram | https://www.instagram.com/
+Kick | https://kick.com/
+WCO | https://www.wco.tv/
+AnikotoTV | https://anikototv.to/home
+Nyaa Torrents | https://nyaa.si/
+PrimeWire | https://primewire.mov/home
+Sync | https://www.sync.com/
+
+# Music | sonemic.png | Visual
+NTS | https://www.nts.live/
+KWSX Radio | https://radio.kwsx.online/
+Radio | https://radio.sillylaird.ca/
+M3U Player | https://junguler.github.io/m3u-player-web/#
+Apple Music | https://music.apple.com/ca/browse
+Youtube Music | https://music.youtube.com/
+SoundCloud | https://soundcloud.com/discover
+BandCamp | https://bandcamp.com/
+RateYourMusic | https://rateyourmusic.com/
+Discogs | https://www.discogs.com/
+Last.FM | https://www.last.fm/user/SillyLaird
+Libre.FM | https://libre.fm/
+Playlistor | https://playlistor.io/
+/mu/ | https://boards.4chan.org/mu/
+Head-Fi | https://www.head-fi.org/
+PitchFork | https://pitchfork.com/
+theneedledrop | https://www.youtube.com/@theneedledrop
+
+# Shopping | shoppingcart.png | Visual
+Amazon Canada | https://www.amazon.ca/
+Amazon USA | https://www.amazon.com/
+Ebay Canada | https://www.ebay.ca/
+Ebay USA | https://www.ebay.com/
+Yahoo Auctions | https://www.jauce.com/
+NewEgg | https://www.newegg.ca/
+Canada Computers | https://www.canadacomputers.com/en/
+Long & McQuade | https://www.long-mcquade.com/
+Temu | https://www.temu.com/
+DHGate | https://www.dhgate.com/
+Ottawa Black Bears | https://ottawablackbears.com/
+Ottawa Senators | https://ottawateamshop.ca/
+Ottawa Redblacks | https://shop.ottawaredblacks.com/
+Belleville Sens | https://shop.bellevillesens.com/
+Ottawa 67's | https://chl.ca/ohl-67s/
+Baltimore Ravens | https://shop.baltimoreravens.com/
+Baltimore Orioles | https://www.mlbshop.com/baltimore-orioles/
+
+# Repository | gitlogo.png | Source
+Git Hub | https://github.com/
+9front | http://9front.org/
+GitWeb Gentoo | https://gitweb.gentoo.org/
+GitHub Ubuntu | https://github.com/ubuntu
+GitHub Arch Linux | https://github.com/archlinux
+GitHub Void Linux | https://github.com/void-linux
+GitHub Babi | https://github.com/asottile/babi
+CodeBerg | https://codeberg.org/
+Git Lain.La | https://gitlab.lain.la/
+Git SDF | https://git.sdf.org/sillylaird
+Git Termer | https://git.termer.net/sillylaird
+SourceHut | https://sr.ht/~sillylaird/
+Bisqwit | https://bisqwit.iki.fi/src/
+Gitee | https://gitee.com/
+ESR GitLab | https://gitlab.com/esr
+Git.Sillylaird.ca | https://git.sillylaird.ca
+CVS.Sillylaird.ca | https://cvs.sillylaird.ca
+
+# BBS | heyuri.png | BBS
+Heyuri | https://heyuri.net/
+PokemonBBS | https://pokemonbbs.com/post/
+Heyaoi | https://www.heyaoi.net/
+Heyaoi Online | https://micro.heyaoi.online/
+/s4s/ | https://boards.4chan.org/s4s/
+futabachannel | https://futabachannel.neocities.org/
+Cooking | https://dat.2chan.net/t/futaba.htm
+Ramen | https://dat.2chan.net/21/futaba.htm
+Train | https://dat.2chan.net/r/futaba.htm
+BaseBall | https://zip.2chan.net/1/futaba.htm
+Soccer | https://zip.2chan.net/12/futaba.htm
+Mahjong | https://may.2chan.net/25/futaba.htm
+Politics | https://nov.2chan.net/35/futaba.htm
+2d Img | https://dat.2chan.net/img2/futaba.htm
+HomeBrew Pc | https://zip.2chan.net/3/futaba.htm
+Consumer Tech | https://dat.2chan.net/48/futaba.htm
+ptt BBS | https://www.ptt.cc/bbs/index.html
+
+# Local News/Weather | https://sirocco.accuweather.com/nx_mosaic_640x480_public/sir/inmasirne.gif | Env
+CP24 | https://www.cp24.com/
+CTV News | https://www.ctvnews.ca/weather
+CBC News | https://www.cbc.ca/news/canada/ottawa
+Global News | https://globalnews.ca/
+JapanToday | https://japantoday.com/
+CGTN | https://www.cgtn.com/
+新华网 (Xinhuanet) | https://www.xinhuanet.com/
+TheGlobeAndMail | https://www.theglobeandmail.com/
+Reuters | https://www.reuters.com/
+Fox News | https://www.foxnews.com/
+Drudge Report | https://www.drudgereport.com/
+JoshWho News | https://news.joshwho.net/
+Current News | https://www.livenowfox.com/live
+Quinte News | https://www.quintenews.com/
+Local Weather | https://weather.com/
+Japan Time | https://www.nict.go.jp/JST/JST5_E.html
+Local Traffic | https://511on.ca/
+
+# Sports | belleville.png | Env
+live.qq | https://live.qq.com/directory/all
+SportsMediaWatch | https://www.sportsmediawatch.com/sports-on-tv-today-games-time-channel/
+Sofascore | https://www.sofascore.com/
+TheAHLInsider | https://theahl.com/
+ESPN | https://www.espn.com/
+ESPN WATCH | https://www.espn.com/watch/
+SportsNet | https://www.sportsnet.ca/
+TSN | https://www.tsn.ca/
+TSN (WATCH) | https://www.tsn.ca/live/
+FloSports | https://www.flosports.tv/
+NLL | https://www.tsn.ca/lacrosse/article/nll-lacrosse-on-tsn-broadcast-schedule-2/
+Plus Nll | https://plus.nll.com/
+TGL Golf | https://tglgolf.com/
+Hanshin Tigers | https://hanshintigers.jp/en/
+Carolina Chaos | https://premierlacrosseleague.com/teams/carolina-chaos
+BellevilleKodiaks | https://bellevillekodiaks.lacrosseshift.com/
+BellevilleSens | https://bellevillesens.com/
+
+# Forums | somethingawful.png | Env
+Something Awful | https://www.somethingawful.com/
+Agora Road | https://forum.agoraroad.com/index.php
+Hacker News | https://news.ycombinator.com/
+lobste.rs | https://lobste.rs/
+Slashdot | https://slashdot.org/
+Ars Tech Forum | https://arstechnica.com/civis/
+Quora | https://www.quora.com/?prevent_redirect=1
+XDA-Developers | https://www.xda-developers.com/
+level1techs | https://forum.level1techs.com/
+DaemonForums | https://daemonforums.org/forumdisplay.php?f=11
+OSDev | https://forum.osdev.org/
+ID Forums | https://idforums.net/
+HellsGamers Forum | https://hellsgamers.com/forums/
+LTT Forum | https://linustechtips.com/
+LowEndTalk | https://lowendtalk.com/
+HardForum | https://hardforum.com/
+GeekHack | https://geekhack.org/index.php
+
+# Video Games | videogame.png | Env
+Steam | https://store.steampowered.com/
+GoG | https://www.gog.com/
+OSRS RuneScape | https://oldschool.runescape.com/
+RSCEvolution | https://rscrevolution.com/
+2004Scape | https://2004.lostcity.rs/
+2005Scape | http://2005.rs/
+UO Outlands | https://uooutlands.com/
+DtxMania | https://dtxmania.net/
+ApprovedDTX | https://approvedtx.blogspot.com/?m=1
+FuruKon | https://dtx.furukon.com/
+DJLain Gitadora | https://djlain.com/gitadora/
+DJLain Pop'n | https://djlain.com/popn/
+IIDX Guide | https://iidx.org/
+Etterna | https://etternaonline.com/
+FFR | https://www.flashflashrevolution.com/
+Realm of Espionage | https://wiki.realmofespionage.xyz/start
+NewGrounds | https://www.newgrounds.com/
diff --git a/startpage/ctvnews.png b/startpage/ctvnews.png
new file mode 100644
index 0000000..87d7065
--- /dev/null
+++ b/startpage/ctvnews.png
Binary files differ
diff --git a/startpage/cytube.png b/startpage/cytube.png
new file mode 100644
index 0000000..6f47350
--- /dev/null
+++ b/startpage/cytube.png
Binary files differ
diff --git a/startpage/ebay.png b/startpage/ebay.png
new file mode 100644
index 0000000..9a75110
--- /dev/null
+++ b/startpage/ebay.png
Binary files differ
diff --git a/startpage/gitlogo.png b/startpage/gitlogo.png
new file mode 100644
index 0000000..34cd85c
--- /dev/null
+++ b/startpage/gitlogo.png
Binary files differ
diff --git a/startpage/heyuri.png b/startpage/heyuri.png
new file mode 100644
index 0000000..46510d5
--- /dev/null
+++ b/startpage/heyuri.png
Binary files differ
diff --git a/startpage/index.php b/startpage/index.php
new file mode 100644
index 0000000..a3791f2
--- /dev/null
+++ b/startpage/index.php
@@ -0,0 +1,454 @@
+<?php
+header('Cache-Control: no-cache, no-store, must-revalidate');
+require_once __DIR__ . '/../partials/i18n.php';
+$lang = 'en';
+?>
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title><?= htmlspecialchars(t('startpage.title'), ENT_QUOTES, 'UTF-8') ?></title>
+
+ <meta name="color-scheme" content="dark light" />
+<?php
+ // The card names this page, not the homepage: og/twitter title + og:url and
+ // the hreflang alternates all point at /startpage/, matching the canonical.
+ $meta = [
+ 'lang' => 'en',
+ 'title' => t('startpage.title'),
+ 'desc' => t('startpage.desc'),
+ 'base' => 'index',
+ 'self_url' => 'https://www.sillylaird.ca/startpage/',
+ 'og_url' => 'https://www.sillylaird.ca/startpage/',
+ 'en_url' => 'https://www.sillylaird.ca/startpage/',
+ 'x_default' => 'https://www.sillylaird.ca/startpage/',
+ 'ja_url' => 'https://www.sillylaird.ca/startpage/',
+ 'zh_url' => 'https://www.sillylaird.ca/startpage/',
+ ];
+ include __DIR__ . '/../partials/head_meta.php';
+?>
+ <link rel="stylesheet" href="/assets/css/fonts.css?v=<?= @filemtime(__DIR__ . '/../assets/css/fonts.css') ?: time() ?>" />
+ <link rel="stylesheet" href="/assets/css/site.css?v=<?= @filemtime(__DIR__ . '/../assets/css/site.css') ?: time() ?>" />
+ <link rel="stylesheet" href="/assets/css/skeleton.css?v=<?= @filemtime(__DIR__ . '/../assets/css/skeleton.css') ?: time() ?>" />
+
+ <link rel="stylesheet" href="startpage.css?v=<?= @filemtime(__DIR__ . '/startpage.css') ?: time() ?>" />
+<?php
+ // Boot banner OpenBSD version: tools/update_openbsd_version.sh (cron) writes
+ // openbsd_version.txt from openbsd.org; JS falls back to a date formula if missing.
+ $openbsd_release = '';
+ $openbsd_ver_file = __DIR__ . '/openbsd_version.txt';
+ if (is_readable($openbsd_ver_file)) {
+ $v = trim((string) @file_get_contents($openbsd_ver_file));
+ if (preg_match('/^\d+\.\d+$/', $v)) {
+ $openbsd_release = $v;
+ }
+ }
+?>
+ <script>window.OPENBSD_RELEASE=<?= json_encode($openbsd_release, JSON_UNESCAPED_SLASHES) ?>;</script>
+ <script defer src="startpage.js?v=<?= @filemtime(__DIR__ . '/startpage.js') ?: time() ?>"></script>
+ <noscript><style>.boot-overlay{display:none!important}</style></noscript>
+</head>
+
+<body class="startpage-booting startpage-comfort comfort-spacing">
+ <a class="skip-link" href="#main">Skip to content</a>
+ <div id="boot-overlay" class="boot-overlay" aria-hidden="true">
+ <div class="boot-log" id="boot-log"></div>
+ </div>
+ <div class="crt-overlay" aria-hidden="true"></div>
+ <?php include '../partials/header.php'; ?>
+
+ <?php
+ $lastfm_initial = '';
+ $lastfm_cache_file = sys_get_temp_dir() . '/sillylaird_lastfm/recent.json';
+ if (is_file($lastfm_cache_file)) {
+ $lastfm_raw = @file_get_contents($lastfm_cache_file);
+ $lastfm_tracks = $lastfm_raw !== false ? json_decode($lastfm_raw, true) : null;
+ if (is_array($lastfm_tracks)) {
+ $pick = null;
+ foreach ($lastfm_tracks as $t) {
+ if (!empty($t['nowplaying'])) { $pick = $t; break; }
+ }
+ if (!$pick) {
+ foreach ($lastfm_tracks as $t) {
+ if (!empty($t['name'])) { $pick = $t; break; }
+ }
+ }
+ if ($pick && !empty($pick['name']) && !empty($pick['artist'])) {
+ $prefix = !empty($pick['nowplaying']) ? 'Now playing: ' : 'Last played: ';
+ $lastfm_initial = $prefix . $pick['name'] . ' — ' . $pick['artist'];
+ }
+ }
+ }
+ ?>
+ <div id="lastfm-widget" class="lastfm-widget">
+ <span class="lastfm-label">♫</span>
+ <span id="lastfm-status"><?= htmlspecialchars($lastfm_initial, ENT_QUOTES, 'UTF-8') ?></span>
+ <a href="https://www.last.fm/user/SillyLaird" class="lastfm-profile" target="_blank" rel="noopener noreferrer">Last.fm Profile</a>
+ </div>
+
+ <section id="konaste-status" class="konaste-status" aria-label="Konaste / BEMANI PC maintenance status">
+ <div class="konaste-head">
+ <h3 class="konaste-title">Konaste / BEMANI PC Status</h3>
+ <small class="konaste-clock">Computer time:
+ <span id="konaste-clock" class="konaste-clock-time"><span data-clock="h">--</span><span class="konaste-clock-colon">:</span><span data-clock="m">--</span><span class="konaste-clock-colon">:</span><span data-clock="s">--</span> <span data-clock="ap" class="konaste-clock-ap"></span></span>
+ <span data-clock="tz" class="konaste-clock-tz"></span>
+ </small>
+ </div>
+
+ <div class="konaste-game" data-game="both">
+ <span class="konaste-dot" data-role="dot"></span>
+ <span class="konaste-names">
+ <a class="konaste-name" href="https://p.eagate.573.jp/game/eacpopn/lively/" target="_blank" rel="noopener noreferrer">pop'n music Lively</a>
+ <span class="konaste-name-sep">•</span>
+ <a class="konaste-name" href="https://p.eagate.573.jp/game/eacgitadora/index.html" target="_blank" rel="noopener noreferrer">GITADORA Konaste</a>
+ <span class="konaste-name-sep">•</span>
+ <a class="konaste-name" href="https://p.eagate.573.jp/game/infinitas/2/index.html" target="_blank" rel="noopener noreferrer">beatmania IIDX INFINITAS</a>
+ </span>
+ <span class="konaste-text" data-role="text">Checking…</span>
+ <span class="konaste-countdown" data-role="countdown"></span>
+ </div>
+
+ <small class="konaste-note">Maintenance times are converted from the server schedule to the connected computer's local time. Unexpected outages can still happen. Schedule via <a href="https://iidx.org/eamuse_maint" target="_blank" rel="noopener noreferrer">iidx.org/eamuse_maint</a>.</small>
+ </section>
+
+
+ <main id="main">
+ <section class="search-section">
+ <div class="top-left-links">
+ <a href="https://grok.com/" target="_blank" rel="noopener noreferrer" class="search-link chatllm-link">Grok</a>
+ <a href="https://www.heyaoi.net/" target="_blank" rel="noopener noreferrer" class="search-link">Heyaoi</a>
+ <a href="https://micro.heyaoi.online/" target="_blank" rel="noopener noreferrer" class="search-link">Heyaoi Online</a>
+ <a href="https://www.sportsmediawatch.com/sports-on-tv-today-games-time-channel/" target="_blank" rel="noopener noreferrer" class="search-link">SportsMediaWatch</a>
+ <nav class="ctx-menu">
+ <ul class="ctx-root">
+ <li class="ctx-parent">
+ <span class="search-link ctx-trigger">Sports <span class="ctx-arrow"></span></span>
+ <ul class="ctx-flyout">
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Watch</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.sportsmediawatch.com/sports-on-tv-today-games-time-channel/" target="_blank" rel="noopener noreferrer" class="ctx-item">SportsMediaWatch</a></li>
+ <li><a href="https://www.espn.com/watch/" target="_blank" rel="noopener noreferrer" class="ctx-item">ESPN WATCH</a></li>
+ <li><a href="https://www.sportsnet.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">SportsNet</a></li>
+ <li><a href="https://www.tsn.ca/live/" target="_blank" rel="noopener noreferrer" class="ctx-item">TSN</a></li>
+ <li><a href="https://www.flosports.tv/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloSports</a></li>
+ <li><a href="https://www.hometeamlive.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">HomeTeamLive</a></li>
+ <li><a href="https://www.dazn.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">DAZN</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Lacrosse</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.tsn.ca/lacrosse/article/nll-lacrosse-on-tsn-broadcast-schedule-2/" target="_blank" rel="noopener noreferrer" class="ctx-item">TSN NLL Schedule</a></li>
+ <li><a href="https://premierlacrosseleague.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">PLL Schedule</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/lacrosse-men/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Lacrosse (M)</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Football</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.cfl.ca/plus" target="_blank" rel="noopener noreferrer" class="ctx-item">CFL+</a></li>
+ <li><a href="https://www.nfl.com/schedules/" target="_blank" rel="noopener noreferrer" class="ctx-item">NFL Schedule</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/football/fbs" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Football</a></li>
+ <li><a href="https://www.flofootball.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloFootball</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Basketball</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.nba.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">NBA Schedule</a></li>
+ <li><a href="https://www.cebl.ca/games" target="_blank" rel="noopener noreferrer" class="ctx-item">CEBL Schedule</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/basketball-men/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Basketball (M)</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/basketball-women/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Basketball (W)</a></li>
+ <li><a href="https://www.flohoops.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloHoops</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Rugby</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.nrl.com/watch/live/" target="_blank" rel="noopener noreferrer" class="ctx-item">NRL Schedule</a></li>
+ <li><a href="https://www.sixnationsrugby.com/en/m6n/fixtures/" target="_blank" rel="noopener noreferrer" class="ctx-item">Six Nations</a></li>
+ <li><a href="https://www.florugby.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloRugby</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Hockey</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.nhl.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">NHL Schedule</a></li>
+ <li><a href="https://theahl.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">AHL Schedule</a></li>
+ <li><a href="https://echl.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">ECHL Schedule</a></li>
+ <li><a href="https://chl.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">CHL Schedule</a></li>
+ <li><a href="https://chl.ca/ohl/schedule/" target="_blank" rel="noopener noreferrer" class="ctx-item">OHL Schedule</a></li>
+ <li><a href="https://chl.ca/whl/schedule/" target="_blank" rel="noopener noreferrer" class="ctx-item">WHL Schedule</a></li>
+ <li><a href="https://chl.ca/lhjmq/schedule/" target="_blank" rel="noopener noreferrer" class="ctx-item">QMJHL Schedule</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/icehockey-men/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Hockey (M)</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/icehockey-women/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Hockey (W)</a></li>
+ <li><a href="https://www.flohockey.tv/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloHockey</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Wrestling</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.ncaa.com/sports/wrestling-men/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Wrestling</a></li>
+ <li><a href="https://www.flowrestling.org/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloWrestling</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Grappling</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flograppling.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloGrappling</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Combat</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flocombat.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloCombat</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Racing</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.formula1.com/en/racing/2026" target="_blank" rel="noopener noreferrer" class="ctx-item">F1 Calendar</a></li>
+ <li><a href="https://www.floracing.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloRacing</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Track</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flotrack.org/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloTrack</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Soccer</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.ncaa.com/scoreboard/soccer-men/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Soccer</a></li>
+ <li><a href="https://www.premierleague.com/fixtures" target="_blank" rel="noopener noreferrer" class="ctx-item">EPL Fixtures</a></li>
+ <li><a href="https://www.uefa.com/uefachampionsleague/fixtures-results/" target="_blank" rel="noopener noreferrer" class="ctx-item">UEFA Champions League</a></li>
+ <li><a href="https://www.mlssoccer.com/schedule/" target="_blank" rel="noopener noreferrer" class="ctx-item">MLS Schedule</a></li>
+ <li><a href="https://www.flofc.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloFC</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Baseball</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.mlb.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">MLB Schedule</a></li>
+ <li><a href="https://www.ncaa.com/scoreboard/baseball/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Baseball</a></li>
+ <li><a href="https://www.flobaseball.tv/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloBaseball</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Softball</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.ncaa.com/scoreboard/softball/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Softball</a></li>
+ <li><a href="https://www.flosoftball.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloSoftball</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Volleyball</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.ncaa.com/scoreboard/volleyball-women/d1" target="_blank" rel="noopener noreferrer" class="ctx-item">NCAA Volleyball (W)</a></li>
+ <li><a href="https://www.flovolleyball.tv/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloVolleyball</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Swimming</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.floswimming.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloSwimming</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Gymnastics</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flogymnastics.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloGymnastics</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Cheer</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flocheer.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloCheer</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Dance</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flodance.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloDance</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Bowling</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flobowling.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloBowling</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Rodeo</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.florodeo.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloRodeo</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Marching</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flomarching.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloMarching</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Cycling</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.flobikes.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloBikes</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Elite</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.floelite.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">FloElite</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Cricket</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.icc-cricket.com/fixtures-results" target="_blank" rel="noopener noreferrer" class="ctx-item">ICC Fixtures</a></li>
+ <li><a href="https://www.iplt20.com/matches/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">IPL Schedule</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Tennis</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.atptour.com/en/tournaments" target="_blank" rel="noopener noreferrer" class="ctx-item">ATP Tour</a></li>
+ <li><a href="https://www.wtatennis.com/tournaments" target="_blank" rel="noopener noreferrer" class="ctx-item">WTA Tour</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Golf</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.pgatour.com/schedule" target="_blank" rel="noopener noreferrer" class="ctx-item">PGA Tour Schedule</a></li>
+ </ul>
+ </li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Aussie Rules</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.afl.com.au/fixture" target="_blank" rel="noopener noreferrer" class="ctx-item">AFL Fixture</a></li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ <a href="https://downloads.khinsider.com/" target="_blank" rel="noopener noreferrer" class="search-link khinsider-link">KHInsider</a>
+ <a href="https://animethemes.moe/" target="_blank" rel="noopener noreferrer" class="search-link">AnimeThemes</a>
+ <a href="https://squid.wtf/" target="_blank" rel="noopener noreferrer" class="search-link squid-link">Squid</a>
+ <a href="https://strmfree.link/" target="_blank" rel="noopener noreferrer" class="search-link">StrmFree</a>
+ <a href="https://anikototv.to/home" target="_blank" rel="noopener noreferrer" class="search-link">AnikotoTV</a>
+ <a href="https://www.wco.tv/" target="_blank" rel="noopener noreferrer" class="search-link">WCO</a>
+ <a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer" class="search-link">FMHY</a>
+ </div>
+ <div class="top-right-links">
+ <a href="https://mail.sillylaird.ca/" target="_blank" rel="noopener noreferrer" class="search-link">Mail</a>
+ <a href="https://mail.google.com/" target="_blank" rel="noopener noreferrer" class="search-link">Gmail</a>
+ <nav class="ctx-menu">
+ <ul class="ctx-root">
+ <li class="ctx-parent">
+ <span class="search-link ctx-trigger"><span class="ctx-arrow"></span> Links</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.google.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">Google</a></li>
+ <li><a href="https://mail.sillylaird.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">Mail</a></li>
+ <li><a href="https://mail.google.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">Gmail</a></li>
+ <li><a href="https://blog.sillylaird.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">Blog</a></li>
+ <li><a href="https://kick.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">Kick</a></li>
+ <li><a href="https://www.nicovideo.jp/" target="_blank" rel="noopener noreferrer" class="ctx-item">Nicovideo</a></li>
+ <li><a href="https://www.bilibili.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">Bilibili</a></li>
+ <li><a href="https://www.youtube.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">YouTube</a></li>
+ <li class="ctx-parent">
+ <span class="ctx-item ctx-has-sub">Radio's</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://radio.garden/listen/gotanno-89-2/9sBfGBXL" target="_blank" rel="noopener noreferrer" class="ctx-item">Radio.Garden</a></li>
+ <li><a href="https://radio.sillylaird.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">Radio</a></li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ <nav class="ctx-menu">
+ <ul class="ctx-root">
+ <li class="ctx-parent">
+ <span class="search-link ctx-trigger"><span class="ctx-arrow"></span> Private Servers</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://2004.lostcity.rs/title" target="_blank" rel="noopener noreferrer" class="ctx-item">2004Scape</a></li>
+ <li><a href="https://2005.rs/" target="_blank" rel="noopener noreferrer" class="ctx-item">2005Scape</a></li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ <nav class="ctx-menu">
+ <ul class="ctx-root">
+ <li class="ctx-parent">
+ <span class="search-link ctx-trigger"><span class="ctx-arrow"></span> Rhythm Games</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://p.eagate.573.jp/game/eacpopn/lively/" target="_blank" rel="noopener noreferrer" class="ctx-item">Pop'n Music Lively</a></li>
+ <li><a href="https://p.eagate.573.jp/game/eacgitadora/index.html" target="_blank" rel="noopener noreferrer" class="ctx-item">Gitadora</a></li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ <nav class="ctx-menu">
+ <ul class="ctx-root">
+ <li class="ctx-parent">
+ <span class="search-link ctx-trigger"><span class="ctx-arrow"></span> Shopping</span>
+ <ul class="ctx-flyout">
+ <li><a href="https://www.ebay.ca/" target="_blank" rel="noopener noreferrer" class="ctx-item">eBay</a></li>
+ <li><a href="https://www.dhgate.com/" target="_blank" rel="noopener noreferrer" class="ctx-item">DHgate</a></li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ <a href="http://9p.sillylaird.ca" target="_blank" rel="noopener noreferrer" class="search-link">9P/9F</a>
+ <a href="http://xp.sillylaird.ca" target="_blank" rel="noopener noreferrer" class="search-link">XP</a>
+ <a href="https://os.sillylaird.ca/" target="_blank" rel="noopener noreferrer" class="search-link">LainOS</a>
+ <a href="https://www.openbsd.org/" target="_blank" rel="noopener noreferrer" class="search-link">OpenBSD</a>
+ <a href="https://9p.io/plan9/" target="_blank" rel="noopener noreferrer" class="search-link">Plan 9</a>
+ <a href="http://9front.org/" target="_blank" rel="noopener noreferrer" class="search-link">9front</a>
+ </div>
+ <h2 class="quick-search-title"><a href="https://4get.sillylaird.ca" target="_blank" rel="noopener noreferrer">4get..</a></h2>
+ <h2 class="quick-search-title"><a href="https://www.youtube.com/" target="_blank" rel="noopener noreferrer">Youtube..</a></h2>
+ <h2 class="quick-search-title"><a href="https://kick.com/" target="_blank" rel="noopener noreferrer">Kick..</a></h2>
+ <h2 class="quick-search-title"><a href="https://rumble.com/" target="_blank" rel="noopener noreferrer">Rumble..</a></h2>
+ <h2 id="unified-search-title"><a href="https://4get.sillylaird.ca" target="_blank" rel="noopener noreferrer">4get Search..</a></h2>
+ <form id="unified-search-form" action="https://4get.sillylaird.ca/web" method="get" target="_blank" rel="noopener noreferrer">
+ <div class="unified-search">
+ <div class="search-engine-picker">
+ <button type="button" id="search-engine-toggle" class="search-engine-toggle" title="Click to switch search engine" aria-label="Switch search engine" aria-haspopup="listbox" aria-expanded="false">4G <span class="search-engine-caret">▾</span></button>
+ <ul id="search-engine-menu" class="search-engine-menu" role="listbox" hidden></ul>
+ </div>
+ <input type="search" id="unified-search-input" name="s" class="search-box unified-search-input" placeholder="Search 4get..." autocomplete="off" />
+ <div class="search-engine-spacer">
+ <button type="button" id="unified-search-clear" class="search-clear" aria-label="Clear search" title="Clear search" hidden><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#ff5555" stroke-width="3" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
+ </div>
+ </div>
+ </form>
+ <div class="card-filter-wrap">
+ <input type="search" id="card-filter" class="search-box card-filter" placeholder="Filter cards & links (type to search)..." autocomplete="off" />
+ <span id="card-filter-count" class="filter-count" aria-live="polite"></span>
+ </div>
+
+ <div class="clock-container">
+ <div id="realtime-date"></div>
+ <div id="realtime-clock" class="clock-time"></div>
+ </div>
+ </section>
+
+ <section class="grid" id="cards-grid">
+ <?php include __DIR__ . "/render_cards.php"; ?>
+ </section>
+ </main>
+
+ <?php
+ // Shared site footer (colophon, counter, badges). Comfort spacing lives in the header.
+ include __DIR__ . '/../partials/footer.php';
+ ?>
+
+</body>
+
+</html>
diff --git a/startpage/kodiaks.png b/startpage/kodiaks.png
new file mode 100644
index 0000000..52bae9a
--- /dev/null
+++ b/startpage/kodiaks.png
Binary files differ
diff --git a/startpage/lain.png b/startpage/lain.png
new file mode 100644
index 0000000..792f486
--- /dev/null
+++ b/startpage/lain.png
Binary files differ
diff --git a/startpage/liivid.png b/startpage/liivid.png
new file mode 100644
index 0000000..6fdb36b
--- /dev/null
+++ b/startpage/liivid.png
Binary files differ
diff --git a/startpage/nav_links.txt b/startpage/nav_links.txt
new file mode 100644
index 0000000..acb37f5
--- /dev/null
+++ b/startpage/nav_links.txt
@@ -0,0 +1,108 @@
+https://blog.sillylaird.ca/
+https://guestbook.sillylaird.ca
+https://www.sillylaird.ca/changelog/
+/journal/
+https://diary.sillylaird.ca/bbs.php
+https://live.sillylaird.ca/
+/gaming/
+http://9p.sillylaird.ca/
+http://localhost:3000/
+https://sillylaird.myportfolio.com/
+https://sillylaird.srht.site/
+https://www.sillylaird.ca/startpage/
+https://sr.ht/~sillylaird/
+https://www.heyaoi.net/
+https://micro.heyaoi.online/
+https://up.heyuri.net/user/boards/sillylaird/
+https://www.basedlii.space/
+https://trello.com/
+https://csgoani.me/
+https://ytmnd.com/
+https://buyvm.net/
+https://www.1984hosting.com/
+https://digdeeper.club/
+https://djlain.com/
+https://freezepeach.social/
+https://polymarket.com/
+https://www.livenowfox.com/live
+https://mail.sillylaird.ca/
+https://mail.google.com/mail/u/0/
+https://ecitizenemail.sealand.io/
+https://anonhosting.io/
+https://tuta.com/
+https://app.secria.me/
+https://proton.me/mail
+https://cock.li/
+https://pissmail.com/
+https://x.com
+https://www.instagram.com/
+https://truthsocial.com/
+https://gab.com/home
+https://www.minds.com/
+https://freesoftwareextremist.com/users/sillylaird
+https://detroitriotcity.com/
+https://noauthority.social/
+https://lemmy.zip/
+https://grok.com/
+https://micro.heyaoi.online/
+https://claude.ai/new
+https://www.testsprite.com/
+https://www.phind.com/
+https://chat.deepseek.com/
+https://chat.qwen.ai/
+https://elephas.app/
+https://www.diabrowser.com/
+https://ollama.com/search
+https://discord.com/
+https://www.remove.bg/
+https://waifu2x.io/
+https://tineye.com/
+https://www.ilovepdf.com/
+https://drive.google.com/
+https://www.sync.com/en/
+https://www.photopea.com/
+https://www.adobe.com/
+https://lobste.rs/
+https://news.ycombinator.com/
+https://www.reuters.com/
+https://www.drudgereport.com/
+https://www.foxnews.com/
+https://www.ctvnews.ca/
+https://www.cbc.ca/
+https://weather.com/
+https://511on.ca/
+https://radio.garden/
+https://rateyourmusic.com/
+https://pitchfork.com/
+http://www.last.fm/
+https://www.discogs.com/
+https://anonradio.net/
+https://r-a-d.io/
+https://91x.fm/
+https://chuo.fm/
+https://www.youtube.com/
+https://www.nicovideo.jp/
+https://www.bilibili.com/
+https://www.tiktok.com/explore
+https://fmhy.net/
+https://deepwebnest.com/
+https://theindex.moe/
+https://theporndude.com/
+https://www.heyuri.net/
+https://endchan.net/
+https://8chan.moe/
+https://doushio.com/moe/
+https://futabachannel.neocities.org/
+https://momizou.neocities.org/
+https://momicha.neocities.org/
+https://5ch.net/
+https://bbs.animanch.com/
+https://www.pornhub.com/
+https://www.xvideos.com/
+https://www.redtube.com/
+https://www.xnxx.com/
+https://www.youporn.com/
+https://www.xhamster.com/
+https://nhentai.net/
+https://gelbooru.com/
+https://jav.vercel.app
diff --git a/startpage/nav_links_xhtml.txt b/startpage/nav_links_xhtml.txt
new file mode 100644
index 0000000..3cc8674
--- /dev/null
+++ b/startpage/nav_links_xhtml.txt
@@ -0,0 +1,108 @@
+<a href="https://blog.sillylaird.ca/">Blog</a><br />
+<a href="/guestbook.php">GuestBook</a><br />
+<a href="https://www.sillylaird.ca/changelog/">ChangeLog</a><br />
+<a href="/journal/">Journal</a><br />
+<a href="https://diary.sillylaird.ca/bbs.php">Diary</a><br />
+<a href="https://live.sillylaird.ca/">Live</a><br />
+<a href="/gaming/">Gaming</a><br />
+<a href="http://9p.sillylaird.ca/">9p.sillylaird.ca</a><br />
+<a href="http://localhost:3000/">Local.Dev</a><br />
+<a href="https://sillylaird.myportfolio.com/">MyPortfolio</a><br />
+<a href="https://sillylaird.srht.site/">(srht.site) SH</a><br />
+<a href="https://www.sillylaird.ca/startpage/">StartPage</a><br />
+<a href="https://sr.ht/~sillylaird/">SourceHut</a><br />
+<a href="https://www.heyaoi.net/">Heyaoi</a><br />
+<a href="https://micro.heyaoi.online/">Heyaoi Online</a><br />
+<a href="https://up.heyuri.net/user/boards/sillylaird/">up@heyuri</a><br />
+<a href="https://www.basedlii.space/">Basedlii.space</a><br />
+<a href="https://trello.com/">Trello</a><br />
+<a href="https://csgoani.me/">csgoani.me</a><br />
+<a href="https://ytmnd.com/">ytmnd</a><br />
+<a href="https://buyvm.net/">BuyVM</a><br />
+<a href="https://www.1984hosting.com/">1984Hosting</a><br />
+<a href="https://digdeeper.club/">DigDeeper</a><br />
+<a href="https://djlain.com/">DJLain</a><br />
+<a href="https://freezepeach.social/">FreezePeach</a><br />
+<a href="https://polymarket.com/">PolyMarket</a><br />
+<a href="https://www.livenowfox.com/live">Current News</a><br />
+<a href="https://mail.sillylaird.ca/">mail@sillylaird.ca</a><br />
+<a href="https://mail.google.com/mail/u/0/">Gmail</a><br />
+<a href="https://ecitizenemail.sealand.io/">SeaLand</a><br />
+<a href="https://anonhosting.io/">Anonhosting</a><br />
+<a href="https://tuta.com/">Tuta</a><br />
+<a href="https://app.secria.me/">Secria</a><br />
+<a href="https://proton.me/mail">Proton</a><br />
+<a href="https://cock.li/">Cockli</a><br />
+<a href="https://pissmail.com/">PissMail</a><br />
+<a href="https://x.com">X/Twitter</a><br />
+<a href="https://www.instagram.com/">Instagram</a><br />
+<a href="https://truthsocial.com/">TruthSocial</a><br />
+<a href="https://gab.com/home">Gab</a><br />
+<a href="https://www.minds.com/">Minds</a><br />
+<a href="https://freesoftwareextremist.com/users/sillylaird">FreeSoftwareEx</a><br />
+<a href="https://detroitriotcity.com/">DetroitRiotCity</a><br />
+<a href="https://noauthority.social/">NoAuthority</a><br />
+<a href="https://lemmy.zip/">Lemmy.zip</a><br />
+<a href="https://grok.com/">Grok</a><br />
+<a href="https://micro.heyaoi.online/">Heyaoi Online</a><br />
+<a href="https://claude.ai/new">Claude</a><br />
+<a href="https://www.testsprite.com/">TestSprite</a><br />
+<a href="https://www.phind.com/">phind</a><br />
+<a href="https://chat.deepseek.com/">DeepSeek</a><br />
+<a href="https://chat.qwen.ai/">Qwen</a><br />
+<a href="https://elephas.app/">Elephas</a><br />
+<a href="https://www.diabrowser.com/">DiaBrowser</a><br />
+<a href="https://ollama.com/search">Ollama</a><br />
+<a href="https://discord.com/">Discord</a><br />
+<a href="https://www.remove.bg/">RemoveBG</a><br />
+<a href="https://waifu2x.io/">Waifu2x</a><br />
+<a href="https://tineye.com/">TinEye</a><br />
+<a href="https://www.ilovepdf.com/">iLovePDF</a><br />
+<a href="https://drive.google.com/">Google Drive</a><br />
+<a href="https://www.sync.com/en/">Sync</a><br />
+<a href="https://www.photopea.com/">Photopea</a><br />
+<a href="https://www.adobe.com/">Adobe</a><br />
+<a href="https://lobste.rs/">Lobsters</a><br />
+<a href="https://news.ycombinator.com/">HackerNews</a><br />
+<a href="https://www.reuters.com/">Reuters</a><br />
+<a href="https://www.drudgereport.com/">Drudge Report</a><br />
+<a href="https://www.foxnews.com/">Fox News</a><br />
+<a href="https://www.ctvnews.ca/">CTV News</a><br />
+<a href="https://www.cbc.ca/">CBC News</a><br />
+<a href="https://weather.com/">Local Weather</a><br />
+<a href="https://511on.ca/">Local Traffic</a><br />
+<a href="https://radio.garden/">Radio.Garden</a><br />
+<a href="https://rateyourmusic.com/">RateYourMusic</a><br />
+<a href="https://pitchfork.com/">PitchFork</a><br />
+<a href="http://www.last.fm/">LAST.FM</a><br />
+<a href="https://www.discogs.com/">Discogs</a><br />
+<a href="https://anonradio.net/">Anonradio</a><br />
+<a href="https://r-a-d.io/">R/a/dio</a><br />
+<a href="https://91x.fm/">Loyalist Radio</a><br />
+<a href="https://chuo.fm/">CHUO Radio</a><br />
+<a href="https://www.youtube.com/">YouTube</a><br />
+<a href="https://www.nicovideo.jp/">NicoVideo</a><br />
+<a href="https://www.bilibili.com/">BiliBili</a><br />
+<a href="https://www.tiktok.com/explore">TikTok</a><br />
+<a href="https://fmhy.net/">FMHY</a><br />
+<a href="https://deepwebnest.com/">DeepWebNest</a><br />
+<a href="https://theindex.moe/">The index (Anime)</a><br />
+<a href="https://theporndude.com/">ThePornDude (XXX)</a><br />
+<a href="https://www.heyuri.net/">Heyuri</a><br />
+<a href="https://endchan.net/">EndChan</a><br />
+<a href="https://8chan.moe/">8chan.moe</a><br />
+<a href="https://doushio.com/moe/">/moe/</a><br />
+<a href="https://futabachannel.neocities.org/">FutabaChannel</a><br />
+<a href="https://momizou.neocities.org/">Momizou</a><br />
+<a href="https://momicha.neocities.org/">Momicha</a><br />
+<a href="https://5ch.net/">5ch</a><br />
+<a href="https://bbs.animanch.com/">Animan BBS</a><br />
+<a href="https://www.pornhub.com/">PornHub</a><br />
+<a href="https://www.xvideos.com/">XVideos</a><br />
+<a href="https://www.redtube.com/">RedTube</a><br />
+<a href="https://www.xnxx.com/">XNXX</a><br />
+<a href="https://www.youporn.com/">YouPorn</a><br />
+<a href="https://www.xhamster.com/">xHamster</a><br />
+<a href="https://nhentai.net/">nhentai</a><br />
+<a href="https://gelbooru.com/">Gelbooru</a><br />
+<a href="https://jav.vercel.app">jav.vercel.app</a><br />
diff --git a/startpage/news.png b/startpage/news.png
new file mode 100644
index 0000000..32d2b8c
--- /dev/null
+++ b/startpage/news.png
Binary files differ
diff --git a/startpage/newspaper.png b/startpage/newspaper.png
new file mode 100644
index 0000000..0e9122e
--- /dev/null
+++ b/startpage/newspaper.png
Binary files differ
diff --git a/startpage/noteback.gif b/startpage/noteback.gif
new file mode 100644
index 0000000..2630a60
--- /dev/null
+++ b/startpage/noteback.gif
Binary files differ
diff --git a/startpage/old-youtube-icon-75.png b/startpage/old-youtube-icon-75.png
new file mode 100644
index 0000000..b16d3ae
--- /dev/null
+++ b/startpage/old-youtube-icon-75.png
Binary files differ
diff --git a/startpage/openbsd_version.txt b/startpage/openbsd_version.txt
new file mode 100644
index 0000000..fbf1779
--- /dev/null
+++ b/startpage/openbsd_version.txt
@@ -0,0 +1 @@
+7.9
diff --git a/startpage/render_cards.php b/startpage/render_cards.php
new file mode 100644
index 0000000..369a5fd
--- /dev/null
+++ b/startpage/render_cards.php
@@ -0,0 +1,76 @@
+<?php
+if (realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'] ?? '')) { http_response_code(404); exit; }
+
+function startpage_valid_card_url(string $url): bool {
+ if ($url === '') return false;
+ if ($url[0] === '/') return !str_starts_with($url, '//') && !str_contains($url, "\0");
+ $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
+ return in_array($scheme, ['http', 'https', 'gemini', 'gopher'], true) && filter_var($url, FILTER_VALIDATE_URL) !== false;
+}
+
+function startpage_valid_card_asset(string $ref): bool {
+ if ($ref === '' || str_contains($ref, "\0")) return false;
+ if (startpage_valid_card_url($ref)) return true;
+ return !str_contains($ref, '..') && preg_match('/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/', $ref);
+}
+
+function startpage_card_data(): array {
+ $file = __DIR__ . '/cards.txt';
+ $raw = is_file($file) ? file_get_contents($file) : '';
+ $cards = [];
+ $cur = null;
+ foreach (explode("\n", str_replace("\r\n", "\n", (string)$raw)) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ if ($line[0] === '#') {
+ if ($cur !== null) $cards[] = $cur;
+ $parts = array_map('trim', explode('|', ltrim($line, '#'), 3));
+ $title = $parts[0] ?? '';
+ $image = $parts[1] ?? '';
+ $alt = $parts[2] ?? 'Visual';
+ if ($title === '' || !startpage_valid_card_asset($image)) { $cur = null; continue; }
+ $cur = ['title' => $title, 'image' => $image, 'alt' => $alt !== '' ? $alt : 'Visual', 'links' => []];
+ continue;
+ }
+ if ($cur === null) continue;
+ $parts = array_map('trim', explode('|', $line, 2));
+ $label = $parts[0] ?? '';
+ $url = $parts[1] ?? '';
+ if ($label !== '' && startpage_valid_card_url($url)) $cur['links'][] = ['label' => $label, 'url' => $url];
+ }
+ if ($cur !== null) $cards[] = $cur;
+ $all = array_values(array_filter($cards, fn($card) => $card['links'] !== []));
+ return $all;
+}
+
+function startpage_seasonal_sports_image(string $image): string {
+ // Sports card logo follows the local season: Belleville Senators (AHL)
+ // October–April, Belleville Kodiaks (OSL Sr B lacrosse) May–September.
+ if ($image !== 'belleville.png') return $image;
+ $month = (int)date('n');
+ return ($month >= 5 && $month <= 9) ? 'kodiaks.png' : 'belleville.png';
+}
+
+$allCardData = startpage_card_data();
+foreach ($allCardData as &$card) {
+ $card['image'] = startpage_seasonal_sports_image($card['image']);
+}
+unset($card);
+?>
+<?php foreach ($allCardData as $card): ?>
+ <article class="card" data-title="<?= htmlspecialchars($card['title'], ENT_QUOTES, 'UTF-8') ?>" data-labels="<?= htmlspecialchars(implode(' ', array_column($card['links'], 'label')), ENT_QUOTES, 'UTF-8') ?>">
+ <div class="card-titlebar">
+ <span class="card-dots" aria-hidden="true"><span></span><span></span><span></span></span>
+ <span class="card-title"><?= htmlspecialchars($card['title'], ENT_QUOTES, 'UTF-8') ?></span>
+ </div>
+ <div class="card-body">
+ <div class="thumb"><img src="<?= htmlspecialchars($card['image'], ENT_QUOTES, 'UTF-8') ?>" alt="<?= htmlspecialchars($card['alt'], ENT_QUOTES, 'UTF-8') ?>" loading="lazy" decoding="async" /></div>
+ <div class="card-links">
+<?php foreach ($card['links'] as $link): ?>
+ <a href="<?= htmlspecialchars($link['url'], ENT_QUOTES, 'UTF-8') ?>" title="<?= htmlspecialchars($link['label'] . ' — ' . $link['url'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') ?></a>
+<?php endforeach; ?>
+ </div>
+ </div>
+ </article>
+
+<?php endforeach; ?>
diff --git a/startpage/roundcube.png b/startpage/roundcube.png
new file mode 100644
index 0000000..d972dbe
--- /dev/null
+++ b/startpage/roundcube.png
Binary files differ
diff --git a/startpage/shoppingcart.png b/startpage/shoppingcart.png
new file mode 100644
index 0000000..fe3f85e
--- /dev/null
+++ b/startpage/shoppingcart.png
Binary files differ
diff --git a/startpage/sm_logo.png b/startpage/sm_logo.png
new file mode 100644
index 0000000..f17bc70
--- /dev/null
+++ b/startpage/sm_logo.png
Binary files differ
diff --git a/startpage/somethingawful.png b/startpage/somethingawful.png
new file mode 100644
index 0000000..e462ec7
--- /dev/null
+++ b/startpage/somethingawful.png
Binary files differ
diff --git a/startpage/sonemic.png b/startpage/sonemic.png
new file mode 100644
index 0000000..f266a34
--- /dev/null
+++ b/startpage/sonemic.png
Binary files differ
diff --git a/startpage/sonymp3.png b/startpage/sonymp3.png
new file mode 100644
index 0000000..b627d5a
--- /dev/null
+++ b/startpage/sonymp3.png
Binary files differ
diff --git a/startpage/startpage-icon.png b/startpage/startpage-icon.png
new file mode 100644
index 0000000..0a6ef90
--- /dev/null
+++ b/startpage/startpage-icon.png
Binary files differ
diff --git a/startpage/startpage.css b/startpage/startpage.css
new file mode 100644
index 0000000..307e9cc
--- /dev/null
+++ b/startpage/startpage.css
@@ -0,0 +1,1892 @@
+:root {
+ --bg: #000000;
+ --text: #8b0000;
+ --accent: #ff0000;
+ --gold: #8b0000;
+ --border: #2a1515;
+ --neon-green: #39ff14;
+ --neon-cyan: #ff1a1a;
+ --accent-cyan: #8b0000;
+ --accent-pink: #a00000;
+ --accent-magenta: #8b0000;
+ --font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Source Code Pro", ui-monospace, SFMono-Regular, "Cascadia Code", Menlo, Consolas, monospace;
+ --lh-body: 1.55;
+ --lh-comfort: 1.85;
+ --link-glow: 255, 0, 0;
+ --btn-glow: 139, 0, 0;
+ --radius: 4px;
+ /* Sharper edges for a retro feel */
+ --logo-size: clamp(80px, 10vw, 100px);
+ --fs-base: clamp(14px, 1.1vw, 17px);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+/* site.css sets html,body{height:100%}, which clamps the page to one viewport
+ and makes the overflowing body scroll inside that box — a second scrollbar
+ next to the browser's. Let the page grow naturally; min-height keeps the
+ footer pinned to the bottom. */
+html,
+body {
+ height: auto;
+ min-height: 100vh;
+ min-height: 100dvh; /* dynamic vh: excludes mobile address bar so content isn't cut off */
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 400 var(--fs-base) var(--font-mono);
+ line-height: var(--lh-body);
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ min-height: 100dvh;
+ position: relative;
+ /* Cyber grid + retro wallpaper */
+ background-image:
+ linear-gradient(rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0.72)),
+ linear-gradient(rgba(255, 0, 0, 0.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 0, 0, 0.03) 1px, transparent 1px),
+ url('wallpaper.jpg');
+ background-size: cover, 28px 28px, 28px 28px, cover;
+ background-position: center, center, center, center;
+ background-attachment: fixed, fixed, fixed, fixed;
+ background-blend-mode: normal;
+}
+
+.crt-overlay {
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 9998;
+ background:
+ repeating-linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.12) 0px,
+ rgba(0, 0, 0, 0.12) 1px,
+ transparent 1px,
+ transparent 3px
+ );
+ opacity: 0.35;
+ mix-blend-mode: multiply;
+}
+
+body::after {
+ content: '';
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 9997;
+ background: radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.45) 100%);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .crt-overlay { display: none; }
+ body.startpage-booting .boot-overlay { display: none !important; }
+ body.startpage-ready { animation: none; }
+ .boot-log .boot-cursor { animation: none; }
+}
+
+/* No scrollbars at any resolution: the overlay never scrolls, and the page
+ behind it is locked while booting. .boot-log is scaled down by JS
+ (fitBootLog) whenever it would exceed the viewport, so nothing is clipped. */
+html:has(> body.startpage-booting),
+body.startpage-booting {
+ overflow: hidden;
+}
+
+.boot-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ overscroll-behavior: none;
+ background: #000;
+ padding: 2rem;
+ transition: opacity 0.35s ease;
+ /* Failsafe: if JS errors, don't trap users on a black screen */
+ animation: boot-dismiss-failsafe 0s 3s forwards;
+}
+
+/* Short/narrow viewports: give the log the padding back as usable room so the
+ auto-fit scale stays readable instead of shrinking to fit 2rem gutters. */
+@media (max-height: 600px), (max-width: 600px) {
+ .boot-overlay { padding: 0.75rem; }
+}
+
+.boot-overlay.boot-fade {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.boot-overlay[hidden] {
+ display: none !important;
+ animation: none;
+}
+
+@keyframes boot-dismiss-failsafe {
+ to {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ }
+}
+
+.boot-log {
+ margin: auto;
+ max-width: 42rem;
+ width: 100%;
+ color: #f2ca30;
+ font: inherit;
+ line-height: 1.6;
+ /* Scale set by JS. Origin is the top edge: an over-tall flex item with
+ margin:auto is start-aligned, so scaling from the centre would leave the
+ shrunk box hanging past the bottom. Below scale 1 the log is centered by
+ flex anyway, so the origin only matters while shrinking. */
+ transform-origin: center top;
+}
+
+.boot-title {
+ color: #f2ca30;
+ margin-bottom: 0.9rem;
+}
+
+.boot-line {
+ white-space: pre-wrap;
+}
+
+.boot-login {
+ margin-top: 0.9rem;
+}
+
+.boot-log .boot-cursor {
+ color: #f2ca30;
+ animation: cursor-blink 0.9s step-end infinite;
+}
+
+body.startpage-ready {
+ animation: boot-reveal 0.45s ease;
+}
+
+@keyframes boot-reveal {
+ from { opacity: 0.55; }
+}
+
+body.startpage-ready .search-section h2 {
+ animation: cursor-blink 1.1s step-end infinite;
+}
+
+@keyframes cursor-blink {
+ 50% { opacity: 0.55; }
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: 0.1s;
+}
+
+a:hover {
+ background: var(--accent);
+ color: #000 !important;
+}
+
+/* Layout Overrides for Unified Header */
+.header-inner {
+ max-width: none !important;
+ margin: 0 !important;
+ padding-left: 20px !important;
+ padding-right: 20px !important;
+}
+
+main,
+footer {
+ max-width: none !important;
+ margin: 0 !important;
+ padding-left: 20px;
+ padding-right: 20px;
+}
+
+/* Search Section: Left Aligned */
+.search-section {
+ padding: 10px 0 40px;
+ text-align: center;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.top-left-links {
+ position: absolute;
+ top: 10px;
+ left: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 5px;
+ text-align: left;
+ z-index: 1;
+ width: max-content;
+}
+
+/* Windows-style cascading right-click menu for the Sports links */
+.ctx-menu,
+.ctx-menu ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.ctx-parent {
+ position: relative;
+}
+
+.ctx-trigger {
+ cursor: pointer;
+ user-select: none;
+}
+
+/* Flyout panels: hidden until parent opened */
+.ctx-flyout {
+ min-width: 170px;
+ display: none;
+ flex-direction: column;
+ padding: 4px;
+ background: #0a0a0a;
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+ box-shadow: 0 0 12px rgba(255, 0, 0, 0.4);
+ z-index: 60;
+ max-height: 80vh;
+ overflow-y: auto;
+ /* scroll works, scrollbar hidden */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.ctx-flyout::-webkit-scrollbar {
+ display: none;
+}
+
+/* Root dropdown (the category list under "Sports") drops straight down */
+.ctx-root > .ctx-parent > .ctx-flyout {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+}
+
+/* Top-left dropdown (Sports) flies out to the RIGHT of the trigger */
+.top-left-links .ctx-root > .ctx-parent > .ctx-flyout {
+ top: 0;
+ left: calc(100% + 6px);
+}
+
+/* Top-right dropdown (Links) flies out to the LEFT of the trigger. */
+.top-right-links .ctx-root > .ctx-parent > .ctx-flyout {
+ top: 0;
+ left: auto;
+ right: calc(100% + 6px);
+}
+
+/* Category link lists float free (fixed) so the scrolling root menu can't clip them;
+ exact left/top set by JS so they cascade to the right and flip near screen edges */
+.ctx-root .ctx-flyout .ctx-flyout {
+ position: fixed;
+ top: 0;
+ left: 0;
+}
+
+.ctx-parent.open > .ctx-flyout {
+ display: flex;
+}
+
+/* Individual menu rows */
+.ctx-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 4px 10px;
+ font-size: 0.95rem;
+ font-weight: bold;
+ white-space: nowrap;
+ color: var(--accent);
+ text-decoration: none;
+ border-radius: 2px;
+ cursor: pointer;
+}
+
+.ctx-item:hover,
+.ctx-parent.open > .ctx-item {
+ background: var(--accent);
+ color: #000 !important;
+}
+
+/* Submenu arrow indicator */
+.ctx-has-sub::after {
+ content: '▶';
+ font-size: 0.8em;
+ opacity: 0.7;
+ display: inline-block;
+ transition: transform 0.2s ease;
+}
+
+/* Open submenu: right-pointing arrow rotates 90deg to point down */
+.ctx-parent.open > .ctx-has-sub::after {
+ transform: rotate(90deg);
+}
+
+/* Both menus use a crisp CSS border triangle instead of the ◀ / ▶ glyphs
+ (the fallback font renders those diagonally). Rotates 90deg down on open. */
+.top-left-links .ctx-arrow,
+.top-left-links .ctx-has-sub::after,
+.top-right-links .ctx-arrow,
+.top-right-links .ctx-has-sub::after {
+ content: '';
+ display: inline-block;
+ width: 0;
+ height: 0;
+ border-top: 8px solid transparent;
+ border-bottom: 8px solid transparent;
+ opacity: 0.85;
+ transform: none;
+ transition: transform 0.2s ease;
+ vertical-align: middle;
+}
+
+/* Top-right triangles point left */
+.top-right-links .ctx-arrow,
+.top-right-links .ctx-has-sub::after {
+ border-right: 11px solid currentColor;
+ border-left: 0;
+}
+
+/* Top-left triangles point right */
+.top-left-links .ctx-arrow,
+.top-left-links .ctx-has-sub::after {
+ border-left: 11px solid currentColor;
+ border-right: 0;
+}
+
+/* Trigger arrows (Sports / Google Services / Radio's) — bigger, matching the old glyph */
+.top-left-links .ctx-arrow,
+.top-right-links .ctx-arrow {
+ border-top-width: 9px;
+ border-bottom-width: 9px;
+}
+.top-right-links .ctx-arrow { margin-right: 0.4em; border-right-width: 12px; }
+.top-left-links .ctx-arrow { margin-left: 0.4em; border-left-width: 12px; }
+.top-left-links .ctx-has-sub::after { margin-left: 0.4em; }
+
+/* Top-right submenus cascade LEFT, so their arrow sits to the LEFT of the
+ label (before it), with the whole row packed to the left edge. */
+.top-right-links .ctx-has-sub {
+ flex-direction: row-reverse;
+ justify-content: flex-end;
+}
+.top-right-links .ctx-has-sub::after {
+ margin-left: 0;
+ margin-right: 0.4em;
+}
+
+/* On open the left-pointing (top-right) triangle rotates down to the bottom */
+.top-right-links .ctx-parent.open > .ctx-has-sub::after {
+ transform: rotate(-90deg);
+}
+
+/* Trigger arrows rotate to point down on open; direction depends on which way they point */
+.ctx-arrow {
+ display: inline-block;
+ transition: transform 0.2s ease;
+}
+
+/* Top-left trigger arrow points right (▶), rotates +90 -> down on open */
+.top-left-links .ctx-parent.open > .ctx-trigger .ctx-arrow {
+ transform: rotate(90deg);
+}
+
+/* Top-right trigger arrow rotates 90deg down (left -> bottom) on open */
+.top-right-links .ctx-parent.open > .ctx-trigger .ctx-arrow {
+ transform: rotate(-90deg);
+}
+
+.search-link {
+ font-size: 1.1rem;
+ font-weight: bold;
+ text-decoration: none;
+ transition: 0.1s;
+ color: var(--accent);
+}
+
+.top-right-links {
+ position: absolute;
+ top: 10px;
+ right: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 5px;
+ z-index: 1;
+ width: max-content;
+}
+
+.search-link:hover {
+ background: var(--accent);
+ color: #000 !important;
+}
+
+body a:hover,
+body a:focus-visible {
+ color: #ffd0d0;
+}
+
+.search-section h2 {
+ color: var(--accent);
+ margin-bottom: 15px;
+ font-size: 1.2rem;
+ font-weight: bold;
+}
+
+.search-section h2 a {
+ color: inherit;
+ text-decoration: none;
+ transition: color 0.1s;
+}
+
+.search-section h2 a:hover,
+.search-section h2 a:focus-visible {
+ color: var(--accent) !important;
+ background: transparent !important;
+ text-shadow:
+ 0 0 4px rgba(255, 0, 0, .95),
+ 0 0 12px rgba(139, 0, 0, .75),
+ 0 0 24px rgba(139, 0, 0, .45);
+ box-shadow:
+ 0 0 0 1px rgba(139, 0, 0, .55),
+ 0 0 10px rgba(255, 0, 0, .45),
+ 0 0 24px -4px rgba(139, 0, 0, .4);
+}
+
+/* Plain destination links stacked above the engine title; same look as it. */
+.search-section h2.quick-search-title {
+ margin-bottom: 6px;
+}
+
+/* First quick link sits on the section's top line; the rest stack under it. */
+.search-section h2.quick-search-title:first-of-type {
+ margin-top: 0;
+}
+
+.search-box {
+ width: 100%;
+ max-width: 584px;
+ background: #000;
+ border: 1px solid var(--accent);
+ padding: 12px;
+ color: var(--accent);
+ font-family: inherit;
+ outline: none;
+ border-radius: var(--radius);
+ transition: box-shadow 0.2s;
+ margin: 0 auto;
+ display: block;
+}
+
+.search-box:focus {
+ box-shadow: 0 0 10px var(--accent);
+}
+
+/* Skeleton sets input[type=...]{font-size:var(--fs-base)} (specificity 0,1,1) and
+ loads after this file, so it wins over plain .search-box. On mobile --fs-base
+ clamps to 14px, which makes iOS Safari auto-zoom (and reflow) on focus. Force
+ >=16px on the search inputs to stop that. !important needed to beat Skeleton. */
+input.search-box { font-size: 16px !important; }
+
+/* Unified search with engine toggle */
+.unified-search {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ max-width: 584px;
+ margin: 0 auto;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ background: #000;
+ position: relative;
+}
+
+.unified-search:focus-within {
+ box-shadow: 0 0 10px var(--accent);
+}
+
+.search-engine-picker {
+ position: relative;
+ display: flex;
+}
+
+.search-engine-toggle {
+ background: #000;
+ color: var(--accent);
+ border: none;
+ border-right: 1px solid var(--accent);
+ font-family: inherit;
+ font-weight: bold;
+ font-size: 1rem;
+ padding: 0 14px;
+ cursor: pointer;
+ min-width: 64px;
+ transition: 0.15s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+
+.search-engine-toggle:hover {
+ background: var(--accent);
+ color: #000;
+}
+
+.search-engine-caret {
+ font-size: 0.7rem;
+ opacity: 0.8;
+}
+
+.search-engine-menu {
+ position: absolute;
+ top: calc(100% + 2px);
+ left: 0;
+ min-width: 180px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ background: #000;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ z-index: 50;
+ text-align: left;
+ box-shadow: 0 0 10px rgba(255, 0, 0, 0.4);
+}
+
+.search-engine-menu[hidden] {
+ display: none;
+}
+
+.search-engine-menu li {
+ padding: 8px 12px;
+ color: var(--accent);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.9rem;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--border);
+}
+
+.search-engine-menu li:last-child {
+ border-bottom: none;
+}
+
+.search-engine-menu li:hover,
+.search-engine-menu li[aria-selected="true"] {
+ background: var(--accent);
+ color: #000;
+}
+
+.search-engine-menu .engine-icon {
+ font-weight: bold;
+ min-width: 28px;
+ text-align: center;
+}
+
+.unified-search .unified-search-input {
+ border: none;
+ border-radius: 0;
+ max-width: none;
+ flex: 1;
+ width: auto;
+ margin: 0;
+ text-align: center;
+}
+
+.unified-search .unified-search-input:focus {
+ box-shadow: none;
+}
+
+/* kill ugly native search clear (webkit) + edge clear, use custom .search-clear */
+.unified-search .unified-search-input::-webkit-search-cancel-button,
+.unified-search .unified-search-input::-webkit-search-decoration {
+ -webkit-appearance: none;
+ appearance: none;
+ display: none;
+}
+.unified-search .unified-search-input::-ms-clear,
+.unified-search .unified-search-input::-ms-reveal {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+.search-engine-spacer {
+ min-width: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-clear {
+ background: #8b0000;
+ border: 1px solid #8b0000;
+ cursor: pointer;
+ padding: 0;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 1;
+ transition: background 0.15s, border-color 0.15s, transform 0.15s;
+}
+
+.search-clear svg {
+ display: block;
+ stroke: #ff5555 !important;
+}
+
+.search-clear svg line {
+ stroke: #ff5555 !important;
+}
+
+.search-clear:hover {
+ background: #ff0000;
+ border-color: #ff0000;
+ transform: scale(1.05);
+}
+
+.search-clear:hover svg,
+.search-clear:hover svg line {
+ stroke: #8b0000 !important;
+}
+
+.search-clear:active {
+ transform: scale(0.92);
+}
+
+.search-clear:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 1px;
+ opacity: 1;
+}
+
+.search-clear[hidden] {
+ display: none;
+}
+
+.clock-container {
+ margin-top: 15px;
+ color: var(--text);
+ font-size: 0.9rem;
+ opacity: 0.8;
+ text-align: center;
+}
+
+.clock-time {
+ color: var(--accent);
+ letter-spacing: 1px;
+}
+
+/* Grid Layout: Left Aligned */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 24px;
+ padding: 0;
+}
+
+.card {
+ border: 1px solid var(--border);
+ background: #050505;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ transition: transform .2s ease, border-color .2s ease, box-shadow .25s ease;
+ min-width: 0;
+ overflow: hidden;
+ border-radius: var(--radius);
+ box-shadow:
+ 0 0 0 1px rgba(255, 0, 0, 0.08),
+ 0 4px 18px rgba(0, 0, 0, 0.45);
+}
+
+.card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px) scale(1.01);
+ box-shadow:
+ 0 0 0 1px rgba(255, 0, 0, 0.45),
+ 0 0 14px rgba(255, 0, 0, 0.25),
+ 0 8px 24px rgba(0, 0, 0, 0.55);
+}
+
+.card-titlebar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: linear-gradient(180deg, #1a0000, #0a0000);
+ border-bottom: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 0.85rem;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
+}
+
+.card-dots {
+ display: inline-flex;
+ gap: 5px;
+}
+
+.card-dots span {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #333;
+ border: 1px solid #555;
+}
+
+.card-dots span:nth-child(1) { background: #ff5f57; border-color: #cc4a44; }
+.card-dots span:nth-child(2) { background: #febc2e; border-color: #c99524; }
+.card-dots span:nth-child(3) { background: #28c840; border-color: #1f9a32; }
+
+.card-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--accent);
+ text-shadow: 0 0 6px rgba(255, 0, 0, 0.35);
+}
+
+.card-body {
+ padding: 15px 15px 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.card.hidden {
+ display: none !important;
+}
+
+.card-filter-wrap {
+ width: 100%;
+ max-width: 584px;
+ margin: 10px auto 0;
+ position: relative;
+}
+
+.card-filter {
+ margin: 0;
+ text-align: center;
+ font-size: 0.95rem;
+ padding: 10px 12px;
+ padding-right: 55px;
+ border-radius: var(--radius);
+ /* always-on active glow, matches focused search look even when not focused */
+ box-shadow: 0 0 10px var(--accent);
+}
+
+/* kill native search clear for the card filter too */
+.card-filter::-webkit-search-cancel-button,
+.card-filter::-webkit-search-decoration {
+ -webkit-appearance: none;
+ appearance: none;
+ display: none;
+}
+.card-filter::-ms-clear {
+ display: none;
+}
+
+.filter-count {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 0.75rem;
+ color: #666;
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0.7;
+}
+
+body.light-mode .filter-count {
+ color: #8b0000;
+}
+
+.card .thumb {
+ width: 100%;
+ height: 70px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ margin-bottom: 15px;
+ opacity: 0.85;
+ transition: opacity .2s ease, transform .2s ease;
+}
+
+.card:hover .thumb {
+ opacity: 1;
+ transform: scale(1.03);
+}
+
+.card img {
+ max-width: 80px;
+ max-height: 100%;
+ object-fit: contain;
+}
+
+.card-links {
+ font-size: 0.95rem;
+ line-height: 1.6;
+ border-top: 1px solid var(--border);
+ width: 100%;
+ padding-top: 16px;
+ text-align: left;
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.card-links a {
+ display: inline-block;
+ width: auto;
+ max-width: 100%;
+ /* Dotted labels ("wiki.realmofespionage") are one unbreakable token: their
+ min-content width sets the grid track and pushes the whole link column
+ past the card's right edge on narrow columns. Let them wrap. */
+ min-width: 0;
+ overflow-wrap: anywhere;
+ padding: 2px 6px 4px;
+ line-height: 1.45;
+ border-radius: 2px;
+ background: #0a0a0a;
+ text-shadow:
+ 0 0 4px rgba(255, 0, 0, .9),
+ 0 0 10px rgba(255, 0, 0, .5);
+ box-shadow:
+ 0 0 0 1px rgba(255, 0, 0, .9),
+ 0 0 6px rgba(255, 0, 0, .35);
+ transition: transform .2s ease, box-shadow .25s ease, background .2s ease;
+}
+
+.card-links a:hover,
+.card-links a:focus-visible {
+ transform: translateY(-1px) scale(1.02);
+ background: color-mix(in srgb, var(--accent) 12%, #0a0a0a);
+ color: #ffd0d0;
+ box-shadow:
+ 0 0 0 1px rgba(var(--link-glow), .65),
+ 0 0 12px rgba(var(--link-glow), .4);
+}
+
+footer {
+ border-top: 1px solid var(--border);
+ margin-top: auto;
+ color: #555;
+}
+
+/* Last.fm now-playing bar */
+.lastfm-widget {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 20px;
+ margin-top: 12px;
+ border-bottom: 1px solid var(--border);
+ background: #050505;
+ font-size: 0.8rem;
+ color: #888;
+ flex-wrap: wrap;
+}
+
+
+.lastfm-label {
+ color: #d51007;
+}
+
+#lastfm-status {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lastfm-profile {
+ color: #d51007;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.lastfm-profile:hover {
+ background: #d51007;
+ color: #000 !important;
+}
+
+/* Phones / touch devices: bigger text, easier tap target, stacked layout */
+@media (max-width: 600px), (hover: none) and (pointer: coarse) and (max-width: 820px) {
+
+ .lastfm-widget {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ padding: 12px 16px;
+ font-size: 0.95rem;
+ text-align: center;
+ }
+
+ .lastfm-label {
+ font-size: 1.2rem;
+ }
+
+ #lastfm-status {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ flex: none;
+ line-height: 1.4;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ }
+
+ #lastfm-status .lastfm-prefix,
+ #lastfm-status .lastfm-track-name,
+ #lastfm-status .lastfm-track-artist {
+ display: block;
+ max-width: 100%;
+ }
+
+ #lastfm-status .lastfm-track-name {
+ font-weight: bold;
+ }
+
+ #lastfm-status .lastfm-sep {
+ display: none;
+ }
+
+ #lastfm-status .lastfm-track-artist::before {
+ content: '— ';
+ opacity: 0.6;
+ }
+
+ .lastfm-profile {
+ align-self: center;
+ padding: 10px 18px;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #d51007;
+ border-radius: 4px;
+ font-weight: bold;
+ }
+}
+
+
+
+/* Persistent red glow halo on the link groups (visible on desktop and touch) */
+.top-left-links .search-link,
+.top-right-links .search-link {
+ display: inline-block;
+ padding: 1px 4px;
+ border-radius: 2px;
+ text-shadow:
+ 0 0 4px rgba(255, 0, 0, .9),
+ 0 0 12px rgba(139, 0, 0, .6),
+ 0 0 24px rgba(139, 0, 0, .35);
+ box-shadow:
+ 0 0 0 1px rgba(255, 0, 0, .5),
+ 0 0 10px rgba(139, 0, 0, .55),
+ 0 0 24px -4px rgba(139, 0, 0, .4);
+}
+
+/* Phone-only: stack the link groups vertically so they don't overlap the search */
+@media (max-width: 600px), (hover: none) and (pointer: coarse) and (max-width: 820px) {
+ .search-section {
+ padding-top: 20px;
+ }
+
+ .top-left-links,
+ .top-right-links {
+ position: static;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ margin-bottom: 20px;
+ text-align: center;
+ z-index: auto;
+ width: 100%;
+ }
+
+ .top-left-links .search-link,
+ .top-right-links .search-link {
+ font-size: 0.95rem;
+ }
+}
+
+@media (max-width: 850px) {
+
+ header,
+ main,
+ footer {
+ padding: 20px;
+ }
+
+ header {
+ flex-direction: column;
+ }
+
+ .nav-columns {
+ width: 100%;
+ }
+
+ .age-modal-content {
+ margin: 20px;
+ padding: 20px;
+ }
+
+ .search-section {
+ padding: 20px 0 20px 0;
+ }
+
+ .search-box {
+ padding: 8px 10px;
+ font-size: 16px; /* >=16px: prevent iOS Safari auto-zoom on focus */
+ }
+
+ .unified-search {
+ max-width: 100%;
+ }
+
+ .card-filter-wrap {
+ max-width: 100%;
+ }
+
+ .search-engine-toggle {
+ min-width: 48px;
+ padding: 0 8px;
+ font-size: 0.9rem;
+ }
+
+ .unified-search .unified-search-input {
+ min-width: 0;
+ }
+}
+
+
+
+/* Light mode: snow/ice startpage theme */
+body.light-mode {
+ --bg: #fff8f1;
+ --surface: #fffdf9;
+ --text: #1a2a33;
+ --ink: #1a2a33;
+ --muted: #5c6b73;
+ --accent: #8b0000;
+ --gold: #8b0000;
+ --border: #d9c9b8;
+ --shadow: rgba(92, 72, 52, .12);
+ --link-glow: 139, 0, 0;
+ background: #fff8f1 !important;
+ background-image: none !important;
+ color: var(--text);
+}
+
+body.light-mode main,
+body.light-mode footer,
+body.light-mode .search-section,
+body.light-mode .grid {
+ background: #fff8f1;
+}
+
+body.light-mode .card,
+body.light-mode .lastfm-widget,
+body.light-mode .ctx-flyout,
+body.light-mode .unified-search,
+body.light-mode .search-box,
+body.light-mode .search-engine-toggle,
+body.light-mode .search-engine-menu,
+body.light-mode .unified-search .unified-search-input,
+body.light-mode .age-modal-content,
+body.light-mode .age-modal-btn,
+body.light-mode .modal-search-input,
+body.light-mode .modal-search-button {
+ background: #ffffff !important;
+ color: var(--text) !important;
+ border-color: var(--border) !important;
+}
+
+body.light-mode .card-links,
+body.light-mode .search-engine-toggle,
+body.light-mode .ctx-flyout,
+body.light-mode .search-engine-menu li,
+body.light-mode footer,
+body.light-mode .lastfm-widget,
+body.light-mode .modal-search {
+ border-color: var(--border) !important;
+}
+
+body.light-mode .top-left-links .search-link,
+body.light-mode .top-right-links .search-link {
+ color: var(--accent);
+ text-shadow: none;
+ box-shadow: none;
+}
+
+body.light-mode .card {
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, .95),
+ 0 0 16px rgba(139, 0, 0, .18),
+ 0 0 34px rgba(255, 255, 255, .88);
+}
+
+body.light-mode .card-links a {
+ background: #fff4f4;
+ color: var(--accent);
+ text-shadow:
+ 0 0 3px rgba(255, 255, 255, 1),
+ 0 0 10px rgba(255, 255, 255, .9),
+ 0 0 18px rgba(139, 0, 0, .28);
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, .95),
+ 0 0 10px rgba(255, 255, 255, .9),
+ 0 0 20px -4px rgba(139, 0, 0, .36);
+}
+
+body.light-mode .ctx-flyout,
+body.light-mode .search-engine-menu {
+ box-shadow: 0 8px 22px var(--shadow);
+}
+
+body.light-mode .ctx-item,
+body.light-mode .search-engine-menu li {
+ color: var(--text);
+ text-shadow: none;
+}
+
+body.light-mode .ctx-parent.open > .ctx-item {
+ background: var(--accent);
+ color: #ffffff !important;
+}
+
+body.light-mode .search-section h2,
+body.light-mode .search-section h2 a,
+body.light-mode .clock-time,
+body.light-mode .lastfm-label,
+body.light-mode .lastfm-profile,
+body.light-mode .age-modal-content h2 {
+ color: var(--accent);
+}
+
+body.light-mode .clock-container,
+body.light-mode .lastfm-widget,
+body.light-mode footer {
+ color: var(--muted);
+}
+
+body.light-mode .card:hover,
+body.light-mode .unified-search:focus-within,
+body.light-mode .search-box:focus {
+ border-color: var(--accent);
+}
+
+body.light-mode .card:hover {
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 1),
+ 0 0 18px rgba(139, 0, 0, .32),
+ 0 0 42px rgba(255, 255, 255, .95);
+}
+
+body.light-mode .search-engine-menu {
+ box-shadow: 0 8px 22px var(--shadow);
+}
+
+body.light-mode button.search-clear {
+ background: #f8e8e8 !important;
+ border-color: var(--border) !important;
+}
+
+body.light-mode .search-clear svg,
+body.light-mode .search-clear svg line {
+ stroke: var(--accent) !important;
+}
+
+body.light-mode button.search-clear:hover {
+ background: var(--accent) !important;
+ border-color: var(--accent) !important;
+}
+
+body.light-mode .search-clear:hover svg,
+body.light-mode .search-clear:hover svg line {
+ stroke: #ffffff !important;
+}
+
+body.light-mode a:hover,
+body.light-mode .search-link:hover,
+body.light-mode .ctx-item:hover,
+body.light-mode .search-engine-toggle:hover,
+body.light-mode .search-engine-menu li:hover,
+body.light-mode .search-engine-menu li[aria-selected="true"],
+body.light-mode .lastfm-profile:hover,
+body.light-mode .card-links a:hover,
+body.light-mode .age-modal-btn:hover,
+body.light-mode .age-modal-content form button:hover {
+ background: var(--accent) !important;
+ color: #ffffff !important;
+ border-color: var(--accent) !important;
+ text-shadow: none;
+}
+
+body.light-mode .age-modal {
+ background: rgba(255, 248, 241, 0.92);
+}
+
+body.light-mode .age-modal-btn.deny:hover {
+ background: #555 !important;
+ border-color: #555 !important;
+ color: #ffffff !important;
+}
+
+body .site-header .brand-text strong,
+body .site-header .brand-text span,
+body .site-header .header-button,
+body .site-header #menu-toggle,
+body .site-header nav.site-nav a,
+body .site-header .lang-menu a {
+ color: var(--accent);
+}
+
+/* ---------------------------------------------------------------------------
+ Mobile / touch: the desktop context menus cascade as absolute/fixed flyouts
+ that run off the right edge and below the viewport. On small / touch screens
+ render every flyout in-flow instead: full-width, stacked under its trigger,
+ nested levels indented (accordion). Kills all overflow. !important is needed
+ to override the inline left/top the JS (positionFlyout) sets and the fixed
+ positioning from the base rules.
+ --------------------------------------------------------------------------- */
+@media (max-width: 600px), (hover: none) and (pointer: coarse) and (max-width: 820px) {
+ .ctx-menu .ctx-flyout {
+ position: static !important;
+ inset: auto !important;
+ left: auto !important;
+ top: auto !important;
+ right: auto !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ max-width: 100% !important;
+ max-height: none !important;
+ box-sizing: border-box;
+ margin: 4px 0;
+ box-shadow: none;
+ overflow: visible !important;
+ }
+
+ /* indent + accent rule on nested submenus so the hierarchy reads clearly */
+ .ctx-menu .ctx-flyout .ctx-flyout {
+ margin: 4px 0 4px 14px;
+ border-left: 2px solid var(--accent);
+ }
+
+ /* left-align rows and give finger-friendly tap targets */
+ .ctx-menu .ctx-item {
+ justify-content: flex-start;
+ padding: 10px 12px;
+ }
+
+ /* Mobile uses a distinct chevron (❯) that rotates down on open, instead of
+ the desktop filled triangles. Applies to both submenu arrows and the
+ top-level trigger arrows (Sports / Google Services / Radio's). */
+ .ctx-menu .ctx-has-sub::after {
+ content: '❯';
+ transform: rotate(0deg);
+ transition: transform 0.2s ease;
+ }
+ .ctx-menu .ctx-parent.open > .ctx-has-sub::after {
+ transform: rotate(90deg);
+ }
+
+ /* Trigger arrows: hide the desktop glyph (◀ / ▶) and show the same chevron,
+ rotating down on open. */
+ .ctx-menu .ctx-arrow { font-size: 0; }
+ .ctx-menu .ctx-arrow::after {
+ content: '❯';
+ font-size: 1rem;
+ display: inline-block;
+ transition: transform 0.2s ease;
+ }
+ .ctx-menu .ctx-parent.open > .ctx-trigger .ctx-arrow {
+ transform: none;
+ }
+ .ctx-menu .ctx-parent.open > .ctx-trigger .ctx-arrow::after {
+ transform: rotate(90deg);
+ }
+
+ /* Drop the desktop triangles on mobile; the chevron (❯) is used instead */
+ .top-left-links .ctx-arrow,
+ .top-left-links .ctx-has-sub::after,
+ .top-right-links .ctx-arrow,
+ .top-right-links .ctx-has-sub::after {
+ border: 0;
+ width: auto;
+ height: auto;
+ margin: 0;
+ }
+ .top-left-links .ctx-has-sub::after,
+ .top-right-links .ctx-has-sub::after { content: '❯'; }
+ /* Mobile accordion: keep chevron on the right (undo desktop left-flip) */
+ .top-right-links .ctx-has-sub {
+ flex-direction: row;
+ justify-content: space-between;
+ }
+}
+
+/* Comfortable line spacing toggle for long link lists */
+.comfort-toggle {
+ margin-top: 12px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #111;
+ color: var(--accent);
+ font: inherit;
+ font-size: 0.82rem;
+ cursor: pointer;
+ transition: background .15s, color .15s, border-color .15s, box-shadow .2s;
+}
+
+.comfort-toggle:hover,
+.comfort-toggle:focus-visible {
+ background: var(--accent);
+ color: #000;
+ outline: none;
+}
+
+.comfort-toggle[aria-pressed="true"] {
+ border-color: var(--neon-green);
+ color: var(--neon-green);
+ box-shadow: 0 0 10px rgba(57, 255, 20, .25);
+}
+
+body.startpage-comfort,
+body.comfort-spacing {
+ line-height: var(--lh-comfort);
+}
+
+body.startpage-comfort .card-body,
+body.startpage-comfort .clock-container,
+body.startpage-comfort .lastfm-widget {
+ line-height: var(--lh-comfort);
+}
+
+body.startpage-comfort .card-links {
+ line-height: var(--lh-comfort);
+ gap: 22px;
+}
+
+body.startpage-comfort .card-links a {
+ line-height: 1.7;
+ padding: 4px 8px 6px;
+}
+
+.search-section h2 {
+ font-weight: 800;
+ position: relative;
+}
+
+.search-section h2::after {
+ content: '';
+ display: block;
+ width: 2.5rem;
+ height: 2px;
+ margin: 6px auto 0;
+ border-radius: 2px;
+ background: linear-gradient(90deg, #8b0000, var(--accent));
+ box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent);
+ animation: sp-neon-pulse 3.5s ease-in-out infinite;
+}
+
+@keyframes sp-neon-pulse {
+ 50% {
+ box-shadow:
+ 0 0 10px color-mix(in srgb, var(--accent) 50%, transparent),
+ 0 0 18px color-mix(in srgb, #8b0000 25%, transparent);
+ }
+}
+
+body.light-mode .comfort-toggle {
+ background: #fff8f1;
+ color: #8b0000;
+ border-color: #e8c8c8;
+}
+
+body.light-mode .comfort-toggle:hover {
+ background: #8b0000;
+ color: #fff;
+}
+
+body.light-mode .comfort-toggle[aria-pressed="true"] {
+ color: #5a0000;
+ border-color: #8b0000;
+ box-shadow: 0 0 10px rgba(139, 0, 0, .2);
+}
+
+/* Konaste / BEMANI PC maintenance status card (under the Last.fm widget) */
+.konaste-status {
+ margin: 12px 20px 0;
+ padding: 12px 16px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: #050505;
+ font-size: 0.8rem;
+ color: #888;
+}
+
+.konaste-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 6px;
+}
+
+.konaste-title {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--accent);
+ letter-spacing: 0.03em;
+}
+
+.konaste-updated { color: #666; }
+
+.konaste-clock { color: #666; }
+
+.konaste-clock-time {
+ color: var(--neon-cyan);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+}
+
+.konaste-clock-colon { opacity: 0.6; margin: 0 1px; }
+
+.konaste-clock-ap { color: var(--neon-cyan); font-size: 0.9em; }
+
+.konaste-clock-tz { color: #666; margin-left: 4px; }
+
+.konaste-names {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.konaste-name-sep { color: #555; flex-shrink: 0; }
+
+.konaste-game {
+ display: grid;
+ grid-template-columns: 12px auto minmax(0, 1fr);
+ align-items: start;
+ column-gap: 10px;
+ row-gap: 5px;
+ padding: 5px 0;
+}
+
+.konaste-dot { grid-column: 1; grid-row: 1; margin-top: 3px; }
+
+.konaste-names { grid-column: 2; grid-row: 1; }
+
+.konaste-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: #555;
+ box-shadow: 0 0 6px rgba(255, 255, 255, 0.15);
+}
+
+.konaste-dot.is-online {
+ background: var(--neon-green);
+ box-shadow: 0 0 8px var(--neon-green);
+}
+
+.konaste-dot.is-maintenance {
+ background: #febc2e;
+ box-shadow: 0 0 8px #febc2e;
+}
+
+.konaste-name {
+ color: #d51007;
+ white-space: nowrap;
+ flex-shrink: 0;
+ font-weight: 600;
+}
+
+.konaste-name:hover {
+ background: #d51007;
+ color: #000 !important;
+}
+
+.konaste-text {
+ grid-column: 3;
+ grid-row: 1;
+ color: #aaa;
+ line-height: 1.55;
+ text-align: right;
+ white-space: pre-line;
+}
+
+.konaste-countdown {
+ grid-column: 3;
+ text-align: right;
+ color: var(--neon-cyan);
+ font-variant-numeric: tabular-nums;
+}
+
+.konaste-note {
+ display: block;
+ margin-top: 8px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border);
+ color: #666;
+ line-height: 1.4;
+}
+
+.konaste-note a { color: #8b0000; }
+
+@media (max-width: 600px), (hover: none) and (pointer: coarse) and (max-width: 820px) {
+ .konaste-status { font-size: 0.9rem; }
+ .konaste-game { padding: 7px 0; }
+ .konaste-dot { width: 12px; height: 12px; }
+}
+
+/* ---------------------------------------------------------------------------
+ Mid-size viewports (portrait monitors, half-screen windows, small laptops):
+ the absolutely-positioned link columns overlap the centered search box
+ between the phone breakpoint (600px) and ~1100px. Pull them into the flow
+ as centered wrapping rows above the search instead. Root dropdowns drop
+ straight down under their trigger; nested flyouts keep the JS fixed
+ positioning, which already clamps to the viewport.
+ On touch screens <=820px the accordion rules above still win (!important).
+ --------------------------------------------------------------------------- */
+@media (min-width: 601px) and (max-width: 1100px) {
+ .search-section {
+ padding-top: 16px;
+ }
+
+ .top-left-links,
+ .top-right-links {
+ position: static;
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 8px 16px;
+ margin-bottom: 12px;
+ text-align: center;
+ z-index: auto;
+ }
+
+ /* Root dropdowns open downward under the trigger instead of sideways */
+ .top-left-links .ctx-root > .ctx-parent > .ctx-flyout,
+ .top-right-links .ctx-root > .ctx-parent > .ctx-flyout {
+ top: calc(100% + 4px);
+ left: 0;
+ right: auto;
+ max-width: 92vw;
+ }
+}
+
+/* Narrow viewports: the 3-column konaste row (dot | names | right-aligned text)
+ squeezes the status text into a sliver. Stack text/countdown under the names. */
+@media (max-width: 850px) {
+ .konaste-game {
+ grid-template-columns: 12px minmax(0, 1fr);
+ }
+
+ .konaste-text {
+ grid-column: 2;
+ grid-row: 2;
+ text-align: left;
+ }
+
+ .konaste-countdown {
+ grid-column: 2;
+ text-align: left;
+ }
+
+ .grid {
+ gap: 16px;
+ }
+}
+
+/* Light mode */
+body.light-mode .konaste-status {
+ background: #f4f4f4;
+ border-color: #d9c2c2;
+ color: #555;
+}
+body.light-mode .konaste-title { color: #b00020; }
+body.light-mode .konaste-text { color: #444; }
+body.light-mode .konaste-updated,
+body.light-mode .konaste-note { color: #888; }
+body.light-mode .konaste-countdown { color: #8b0000; }
+body.light-mode .konaste-name,
+body.light-mode .konaste-note a { color: #b00020; }
+
+/* ===== OpenBSD-style boot screen additions ===== */
+.boot-log {
+ text-align: center;
+}
+/* Whole boot log is centered (ASCII blocks keep their own left-aligned
+ internal alignment via .boot-ascii-wrap's column flex). */
+.boot-log .boot-line,
+.boot-log .boot-login,
+.boot-log .boot-title {
+ text-align: center;
+}
+.boot-avatar {
+ width: clamp(72px, 10vw, 96px);
+ height: clamp(72px, 10vw, 96px);
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid #f2ca30;
+ box-shadow: 0 0 14px rgba(242, 202, 48, 0.55);
+ display: block;
+ margin: 0 auto 10px;
+}
+/* Lain hero card pinned above Puffy's bubble column. The wrap reserves the
+ avatar's height as padding so the absolute placement never clips the ASCII
+ or the lines above it. */
+.boot-puffy-wrap {
+ position: relative;
+ display: inline-block;
+ padding-top: calc(clamp(72px, 10vw, 96px) + 10px);
+ /* Right padding just under half an avatar: lands the avatar centre on the
+ top bubble without escaping the wrap, which .boot-ascii-wrap clips
+ horizontally. Scales with the avatar so the alignment holds at every
+ clamped ASCII size. */
+ padding-right: calc(clamp(72px, 10vw, 96px) * 0.385);
+}
+.boot-puffy-wrap .boot-avatar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: 0;
+}
+/* Puffy stacked over the OpenBSD wordmark. */
+.boot-ascii-wrap {
+ /* overflow-x:hidden alone forces overflow-y to auto, which put a real
+ scrollbar inside the boot screen. `clip` has no such side effect: the
+ wide ASCII is still clipped horizontally, y stays visible, nothing
+ scrolls. `overflow: hidden` is the fallback for pre-Chrome-90 UAs. */
+ overflow: hidden;
+ overflow-x: clip;
+ overflow-y: visible;
+ margin-bottom: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+.boot-openbsd-wordmark {
+ display: inline-block;
+ margin: 0;
+ color: #f2ca30;
+ text-shadow:
+ 0 0 8px rgba(242, 202, 48, 0.65),
+ 0 0 18px rgba(242, 202, 48, 0.3);
+ font-family: var(--font-mono);
+ font-size: clamp(4px, min(0.85vw, 0.95vh), 10px);
+ line-height: 1.15;
+ text-align: left;
+ white-space: pre;
+}
+/* Puffy is 23 lines tall (Gentoo's g was 9) — smaller type keeps the whole
+ boot log on screen at 1080p and under. */
+.boot-puffy-ascii {
+ display: inline-block;
+ margin: 0;
+ color: #f2ca30;
+ text-shadow: 0 0 6px rgba(242, 202, 48, 0.4);
+ font-family: var(--font-mono);
+ font-size: clamp(4px, min(0.9vw, 1.0vh), 11px);
+ line-height: 1.15;
+ text-align: left;
+ white-space: pre;
+}
+.boot-log .boot-dim { color: #f2ca30; }
+.boot-log .boot-user {
+ color: #f2ca30;
+ font-weight: 600;
+ text-shadow: 0 0 6px rgba(242, 202, 48, 0.55);
+}
+.boot-log .boot-path { color: #f2ca30; font-weight: 600; }
+.boot-log .boot-line,
+.boot-log .boot-svc,
+.boot-log .boot-login {
+ color: #f2ca30;
+}
+
+/* ---------------------------------------------------------------------------
+ Responsive pass: portrait / vertical monitors + denser desktop grid.
+ The hero used to eat ~440px of height on 1080p, pushing every card below
+ the fold; portrait viewports over 1100px kept the absolute side link
+ columns and squeezed the search box into the middle third.
+ --------------------------------------------------------------------------- */
+
+/* Tighter quick-link stack (same type, less air between the rules). */
+.search-section h2.quick-search-title {
+ line-height: 1.25;
+ margin-bottom: 2px;
+}
+
+.search-section h2.quick-search-title::after {
+ margin-top: 4px;
+}
+
+/* Desktop hero compaction: cards start above the fold on 1080p. */
+@media (min-width: 851px) {
+ .search-section {
+ padding: 10px 0 22px;
+ }
+
+ .clock-container {
+ margin-top: 10px;
+ }
+
+ /* More cards per row: 200px tracks + tighter gutters. */
+ .grid {
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 18px;
+ }
+}
+
+/* Wide screens have spare horizontal room: let the search inputs use it. */
+@media (min-width: 1400px) {
+ .unified-search,
+ .card-filter-wrap,
+ .search-box {
+ max-width: 680px;
+ }
+}
+
+@media (min-width: 1600px) {
+ .grid {
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+ gap: 20px;
+ }
+}
+
+/* Portrait / vertical monitors and tablets: drop the absolute side columns so
+ the search block gets the full width instead of the middle third. */
+@media (orientation: portrait) and (min-width: 601px) and (max-width: 1500px) {
+ .search-section {
+ padding-top: 12px;
+ }
+
+ .top-left-links,
+ .top-right-links {
+ position: static;
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 8px 16px;
+ margin-bottom: 10px;
+ text-align: center;
+ z-index: auto;
+ }
+
+ /* Root dropdowns open downward under the trigger instead of sideways */
+ .top-left-links .ctx-root > .ctx-parent > .ctx-flyout,
+ .top-right-links .ctx-root > .ctx-parent > .ctx-flyout {
+ top: calc(100% + 4px);
+ left: 0;
+ right: auto;
+ max-width: 92vw;
+ }
+}
+
+/* Tall-and-narrow portrait (vertical 1080p panel): keep card text legible but
+ pack more columns than the default 220px track would allow. */
+@media (orientation: portrait) and (min-width: 900px) {
+ .grid {
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 16px;
+ }
+}
+
+/* The search form is a flex item, so it shrink-wrapped to ~384px and the
+ 584/680px max-width on .unified-search never applied. Let it fill the row. */
+#unified-search-form {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+/* Short desktop viewports (720p, laptop panels): shave the hero further so the
+ first card row clears the fold. */
+@media (min-width: 851px) and (max-height: 820px) {
+ .search-section {
+ padding: 6px 0 14px;
+ }
+
+ .search-section h2.quick-search-title {
+ margin-bottom: 0;
+ }
+
+ .clock-container {
+ margin-top: 6px;
+ }
+}
+
+/* Red/dark-red terminal cursor on startpage controls (overrides cursor:pointer). */
+@media (hover: hover) and (pointer: fine) {
+ a,
+ button,
+ summary,
+ .search-link,
+ .ctx-item,
+ .ctx-trigger,
+ .comfort-toggle,
+ .search-engine-menu li,
+ .card,
+ label[for],
+ input[type="checkbox"],
+ input[type="radio"],
+ select {
+ cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='20' viewBox='0 0 16 20'%3E%3Crect x='6' y='0' width='4' height='14' fill='%23ff1a1a'/%3E%3Crect x='4' y='14' width='8' height='4' fill='%238b0000'/%3E%3C/svg%3E") 8 10, pointer;
+ }
+}
diff --git a/startpage/startpage.js b/startpage/startpage.js
new file mode 100644
index 0000000..f9040c9
--- /dev/null
+++ b/startpage/startpage.js
@@ -0,0 +1,824 @@
+(function () {
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ function updateClock() {
+ const dateEl = byId('realtime-date');
+ const timeEl = byId('realtime-clock');
+ if (!dateEl || !timeEl) return;
+
+ const now = new Date();
+ const options = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' };
+ dateEl.textContent = now.toLocaleDateString(undefined, options).toUpperCase();
+ timeEl.textContent = now.toLocaleTimeString();
+ }
+
+ setInterval(updateClock, 1000);
+ updateClock();
+
+ // Last.fm now-playing widget
+ const LASTFM_CACHE_KEY = 'lastfmLastGood';
+ const LASTFM_BASE_INTERVAL = 10000;
+ const LASTFM_MAX_INTERVAL = 60000;
+ let lastfmFailures = 0;
+ let lastfmTimer = null;
+
+ function renderLastFmTrack(track) {
+ const statusEl = byId('lastfm-status');
+ const widgetEl = byId('lastfm-widget');
+ if (!statusEl || !track || !track.name || !track.artist) return;
+ const prefix = track.nowplaying ? 'Now playing: ' : 'Last played: ';
+ statusEl.innerHTML = '';
+ const prefixEl = document.createElement('span');
+ prefixEl.className = 'lastfm-prefix';
+ prefixEl.textContent = prefix;
+ const nameEl = document.createElement('span');
+ nameEl.className = 'lastfm-track-name';
+ nameEl.textContent = track.name;
+ const sepEl = document.createElement('span');
+ sepEl.className = 'lastfm-sep';
+ sepEl.textContent = ' — ';
+ const artistEl = document.createElement('span');
+ artistEl.className = 'lastfm-track-artist';
+ artistEl.textContent = track.artist;
+ statusEl.appendChild(prefixEl);
+ statusEl.appendChild(nameEl);
+ statusEl.appendChild(sepEl);
+ statusEl.appendChild(artistEl);
+ if (widgetEl) widgetEl.style.display = '';
+ }
+
+
+ (function primeFromCache() {
+ try {
+ const raw = localStorage.getItem(LASTFM_CACHE_KEY);
+ if (raw) renderLastFmTrack(JSON.parse(raw));
+ } catch (_) {}
+ })();
+
+ function scheduleLastFm() {
+ const backoff = Math.min(
+ LASTFM_MAX_INTERVAL,
+ LASTFM_BASE_INTERVAL * Math.pow(2, lastfmFailures)
+ );
+ clearTimeout(lastfmTimer);
+ lastfmTimer = setTimeout(updateLastFm, backoff);
+ }
+
+ function updateLastFm() {
+ const statusEl = byId('lastfm-status');
+ if (!statusEl) return;
+
+ fetch('/api/lastfm.php')
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (data) {
+ if (data && data.error) throw new Error(data.message || 'api error');
+ const tracks = Array.isArray(data) ? data : [];
+ // Prefer a currently-playing track; otherwise fall back to the most
+ // recent scrobble so the widget always has something to show.
+ const track = tracks.find(function (t) { return t && t.nowplaying; })
+ || tracks.find(function (t) { return t && t.name; })
+ || null;
+ if (track) {
+ renderLastFmTrack(track);
+ try { localStorage.setItem(LASTFM_CACHE_KEY, JSON.stringify(track)); } catch (_) {}
+ }
+ lastfmFailures = 0;
+ })
+ .catch(function () {
+ // Never show an error message. Keep whatever is currently rendered
+ // (primed from localStorage on load), or leave the slot blank.
+ lastfmFailures++;
+ })
+ .finally(scheduleLastFm);
+ }
+
+ updateLastFm();
+
+ document.addEventListener('visibilitychange', function () {
+ if (document.visibilityState === 'visible') {
+ lastfmFailures = 0;
+ updateLastFm();
+ }
+ });
+
+ // Unified search engine switcher
+ const SEARCH_ENGINES = [
+ { key: 'google', label: 'G', title: 'Google Search..', placeholder: 'Search Google...', action: 'https://www.google.ca/search', param: 'q', site: 'https://www.google.ca' },
+ { key: 'brave', label: 'B', title: 'Brave Search..', placeholder: 'Search Brave...', action: 'https://search.brave.com/search', param: 'q', site: 'https://search.brave.com' },
+ { key: 'startpage', label: 'SP', title: 'Startpage Search..', placeholder: 'Search Startpage...', action: 'https://www.startpage.com/sp/search', param: 'query', site: 'https://www.startpage.com' },
+ { key: 'youtube', label: 'YT', title: 'YouTube Search..', placeholder: 'Search YouTube...', action: 'https://www.youtube.com/results', param: 'search_query', site: 'https://www.youtube.com' },
+ { key: 'sillylaird',label: 'SL', title: 'SillyLaird Search..', placeholder: 'Search SillyLaird...', action: 'https://search.sillylaird.ca/search', param: 'q', site: 'https://search.sillylaird.ca' },
+ { key: '4get', label: '4G', title: '4get Search..', placeholder: 'Search 4get...', action: 'https://4get.sillylaird.ca/web', param: 's', site: 'https://4get.sillylaird.ca' }
+ ];
+ const SEARCH_ENGINE_KEY = 'startpageSearchEngine_v2';
+
+ (function initUnifiedSearch() {
+ const form = byId('unified-search-form');
+ const input = byId('unified-search-input');
+ const toggle = byId('search-engine-toggle');
+ const menu = byId('search-engine-menu');
+ const title = byId('unified-search-title');
+ const clearBtn = byId('unified-search-clear');
+ if (!form || !input || !toggle || !menu) return;
+
+ function syncClear() {
+ if (!clearBtn) return;
+ clearBtn.hidden = input.value.length === 0;
+ }
+ if (clearBtn) {
+ input.addEventListener('input', syncClear);
+ clearBtn.addEventListener('click', function () {
+ input.value = '';
+ syncClear();
+ input.focus();
+ });
+ syncClear();
+ }
+
+ let idx = 0;
+ try {
+ const saved = localStorage.getItem(SEARCH_ENGINE_KEY);
+ if (saved) {
+ const savedIdx = SEARCH_ENGINES.findIndex(function (e) { return e.key === saved; });
+ if (savedIdx >= 0) idx = savedIdx;
+ } else {
+ idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; });
+ }
+ } catch (_) {
+ idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; });
+ }
+ if (idx < 0) idx = 0;
+
+ SEARCH_ENGINES.forEach(function (e, i) {
+ const li = document.createElement('li');
+ li.setAttribute('role', 'option');
+ li.dataset.index = String(i);
+ li.innerHTML = '<span class="engine-icon"></span><span class="engine-name"></span>';
+ li.querySelector('.engine-icon').textContent = e.label;
+ li.querySelector('.engine-name').textContent = e.title.replace(/\.\.$/, '');
+ li.addEventListener('click', function () {
+ idx = i;
+ apply(idx);
+ closeMenu();
+ input.focus();
+ });
+ menu.appendChild(li);
+ });
+
+ function apply(i) {
+ const e = SEARCH_ENGINES[i];
+ form.setAttribute('action', e.action);
+ input.setAttribute('name', e.param);
+ input.setAttribute('placeholder', e.placeholder);
+ toggle.innerHTML = '';
+ toggle.appendChild(document.createTextNode(e.label + ' '));
+ const caret = document.createElement('span');
+ caret.className = 'search-engine-caret';
+ caret.textContent = '▾';
+ toggle.appendChild(caret);
+ toggle.setAttribute('title', 'Search engine: ' + e.title);
+ if (title) {
+ title.innerHTML = '<a href="' + e.site + '" target="_blank" rel="noopener noreferrer">' + e.title + '</a>';
+ }
+ Array.prototype.forEach.call(menu.children, function (li, li_i) {
+ li.setAttribute('aria-selected', li_i === i ? 'true' : 'false');
+ });
+ try { localStorage.setItem(SEARCH_ENGINE_KEY, e.key); } catch (_) {}
+ }
+
+ function openMenu() {
+ menu.hidden = false;
+ toggle.setAttribute('aria-expanded', 'true');
+ }
+ function closeMenu() {
+ menu.hidden = true;
+ toggle.setAttribute('aria-expanded', 'false');
+ }
+
+ toggle.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ if (menu.hidden) openMenu(); else closeMenu();
+ });
+
+ document.addEventListener('click', function (ev) {
+ if (menu.hidden) return;
+ if (ev.target === toggle || toggle.contains(ev.target)) return;
+ if (menu.contains(ev.target)) return;
+ closeMenu();
+ });
+
+ document.addEventListener('keydown', function (ev) {
+ if (ev.key === 'Escape' && !menu.hidden) closeMenu();
+ });
+
+ apply(idx);
+ })();
+
+})(); // close the main outer IIFE that wraps clock/lastfm/unified/etc.
+
+ // Live card filter (works on data-attrs emitted by render_cards.php)
+ (function initCardFilter() {
+ const byId = (id) => document.getElementById(id);
+ const filterInput = byId('card-filter');
+ const grid = byId('cards-grid');
+ const countEl = byId('card-filter-count');
+ if (!filterInput || !grid) return;
+
+ const cards = Array.from(grid.querySelectorAll('.card'));
+ if (!cards.length) return;
+
+ // Ensure clean state (in case of previous inline or classes)
+ cards.forEach(function (c) { c.classList.remove('hidden'); });
+
+ // Debug (open console if needed): console.log('[startpage filter] initialized with', cards.length, 'cards');
+
+ function normalize(s) {
+ return (s || '').toLowerCase().trim();
+ }
+
+ function applyFilter() {
+ const q = normalize(filterInput.value);
+ let visible = 0;
+ cards.forEach(function (card) {
+ const title = normalize(card.dataset.title);
+ const labels = normalize(card.dataset.labels);
+ const hay = title + ' ' + labels;
+ const match = !q || hay.indexOf(q) !== -1;
+ card.classList.toggle('hidden', !match);
+ if (match) visible++;
+ });
+ if (countEl) {
+ countEl.textContent = q ? (visible + ' / ' + cards.length) : '';
+ }
+ }
+
+ filterInput.addEventListener('input', applyFilter);
+
+ // Keyboard: '/' focuses the card filter (when not in input already)
+ document.addEventListener('keydown', function (e) {
+ const ae = document.activeElement;
+ const isTyping = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable);
+ if (e.key === '/' && !isTyping) {
+ e.preventDefault();
+ filterInput.focus();
+ filterInput.select();
+ }
+ if (e.key === 'Escape' && ae === filterInput) {
+ filterInput.value = '';
+ applyFilter();
+ filterInput.blur();
+ }
+ });
+
+ // Initial
+ applyFilter();
+ })();
+
+ // Keyboard help overlay (?)
+ (function initKeyboardHelp() {
+ document.addEventListener('keydown', function (e) {
+ if (e.key === '?' && !e.shiftKey) {
+ const ae = document.activeElement;
+ if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;
+ e.preventDefault();
+ let help = document.getElementById('kbd-help');
+ if (!help) {
+ help = document.createElement('div');
+ help.id = 'kbd-help';
+ help.style.cssText = 'position:fixed;bottom:12px;right:12px;background:#111;border:1px solid #8b0000;padding:10px 14px;font-size:0.8rem;z-index:999;color:#8b0000;max-width:280px;line-height:1.5;';
+ help.innerHTML = '<strong>Keyboard</strong><br>/ — focus card filter<br>Esc — clear / close<br>? — toggle this<br>Click engine picker or cards<br>Theme toggle in header';
+ document.body.appendChild(help);
+ const remover = function (ev) {
+ if (ev.key === '?' || ev.key === 'Escape') {
+ help.remove();
+ document.removeEventListener('keydown', remover);
+ }
+ };
+ document.addEventListener('keydown', remover);
+ } else {
+ help.remove();
+ }
+ }
+ });
+ })();
+
+/* Click-to-open cascading Sports menu (left click toggles, like a context menu) */
+document.querySelectorAll('.ctx-menu').forEach(function (menu) {
+ function closeAll() {
+ menu.querySelectorAll('.ctx-parent.open').forEach(function (p) {
+ p.classList.remove('open');
+ });
+ }
+
+ // A "trigger" is any element that owns a flyout: the Sports root or a category label
+ menu.querySelectorAll('.ctx-trigger, .ctx-has-sub').forEach(function (trigger) {
+ trigger.addEventListener('click', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ const parent = trigger.closest('.ctx-parent');
+ const wasOpen = parent.classList.contains('open');
+ // close siblings at the same level
+ const siblings = parent.parentElement.querySelectorAll(':scope > .ctx-parent.open');
+ siblings.forEach(function (s) { if (s !== parent) s.classList.remove('open'); });
+ // toggle this one (also closes its descendants when collapsing)
+ if (wasOpen) {
+ parent.classList.remove('open');
+ parent.querySelectorAll('.ctx-parent.open').forEach(function (d) { d.classList.remove('open'); });
+ } else {
+ parent.classList.add('open');
+ positionFlyout(parent);
+ }
+ });
+ });
+
+ // Place a category's (fixed-position) link list beside its label, cascading right,
+ // flipping left / clamping when it would run off a screen edge.
+ // Skips the root dropdown (direct child of .ctx-root), which CSS drops straight down.
+ function positionFlyout(parent) {
+ const flyout = parent.querySelector(':scope > .ctx-flyout');
+ if (!flyout || parent.parentElement.classList.contains('ctx-root')) return;
+
+ const label = parent.getBoundingClientRect();
+ const box = parent.parentElement.getBoundingClientRect(); // the whole menu box (incl. scrollbar)
+ const fw = flyout.offsetWidth;
+ const fh = flyout.offsetHeight;
+ const margin = 4;
+ const gap = 2;
+
+ // Top-right menus cascade LEFT (toward screen centre); everything else cascades right.
+ // Flip to the other side only when the preferred side runs off the viewport edge.
+ const openLeft = parent.closest('.top-right-links') !== null;
+ let left;
+ if (openLeft) {
+ left = box.left - fw - gap; // open to the left of the whole box
+ if (left < margin) {
+ left = box.right + gap; // no room left: flip to the right
+ }
+ } else {
+ left = box.right + gap; // top-left menus only ever cascade right (match the ▶ arrow)
+ }
+ if (left < margin) left = margin;
+ if (left + fw > window.innerWidth - margin) left = window.innerWidth - fw - margin;
+
+ // align top with the label, clamp into the viewport
+ let top = label.top;
+ if (top + fh > window.innerHeight - margin) {
+ top = Math.max(margin, window.innerHeight - fh - margin);
+ }
+
+ flyout.style.left = left + 'px';
+ flyout.style.top = top + 'px';
+ }
+
+ // Clicking a real link closes the whole menu
+ menu.querySelectorAll('a.ctx-item').forEach(function (a) {
+ a.addEventListener('click', closeAll);
+ });
+
+ // Scrolling the category list closes any open (fixed) submenu so it can't float detached
+ const rootFlyout = menu.querySelector('.ctx-root > .ctx-parent > .ctx-flyout');
+ if (rootFlyout) {
+ rootFlyout.addEventListener('scroll', function () {
+ rootFlyout.querySelectorAll(':scope > .ctx-parent.open').forEach(function (p) {
+ p.classList.remove('open');
+ });
+ });
+ }
+
+ // Click outside closes everything
+ document.addEventListener('click', function (e) {
+ if (!menu.contains(e.target)) closeAll();
+ });
+});
+
+/* Auto-extend the red section box so it always contains the absolutely-positioned link columns */
+(function () {
+ function fitSectionToLinks() {
+ const section = document.querySelector('.search-section');
+ if (!section) return;
+ const groups = section.querySelectorAll('.top-left-links, .top-right-links');
+ let needed = 0;
+ groups.forEach(function (g) {
+ // offsetTop (relative to section) + full height of the link column
+ needed = Math.max(needed, g.offsetTop + g.offsetHeight);
+ });
+ // small breathing room below the last link
+ section.style.minHeight = needed > 0 ? (needed + 20) + 'px' : '';
+ }
+
+ window.addEventListener('load', fitSectionToLinks);
+ window.addEventListener('resize', fitSectionToLinks);
+ if (document.readyState === 'complete') fitSectionToLinks();
+})();
+
+/* Subtle terminal boot sequence on first visit per session */
+(function () {
+ var overlay = document.getElementById('boot-overlay');
+ var logEl = document.getElementById('boot-log');
+ var body = document.body;
+ if (!overlay || !logEl) return;
+
+ var skipBoot = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (skipBoot || sessionStorage.getItem('startpageBooted') === '1') {
+ overlay.hidden = true;
+ body.classList.remove('startpage-booting');
+ body.classList.add('startpage-ready');
+ return;
+ }
+
+ /* OpenBSD /etc/rc prints plain lines, no OpenRC stars or [ ok ] brackets. */
+ var steps = [
+ 'savecore: no core dump',
+ 'checking quotas: done.',
+ 'mounting /startpage/cards.txt',
+ 'starting network daemons: sshd httpd sndiod.',
+ 'starting local daemons: cron scrobbler.',
+ 'bringing up the Wired.'
+ ];
+ var idx = 0;
+
+ function el(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text) n.textContent = text;
+ return n;
+ }
+
+ /* The overlay never scrolls (CSS overflow:hidden), so the log has to fit.
+ Measure the untransformed layout size (scrollWidth/Height ignore the
+ transform) against the overlay's content box and scale down to fit.
+ Runs after every append and on resize/orientation change. */
+ /* body{overflow:hidden} only reaches the viewport if the UA propagates it,
+ and here it does not — the document stayed scrollable behind the overlay.
+ Lock the root directly; CSS does the same via :has for pre-JS paint. */
+ var rootStyle = document.documentElement.style;
+ var prevRootOverflow = rootStyle.overflow;
+ rootStyle.overflow = 'hidden';
+
+ var fitRaf = 0;
+ function fitBootLog() {
+ fitRaf = 0;
+ var cs = window.getComputedStyle(overlay);
+ var availW = overlay.clientWidth
+ - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ var availH = overlay.clientHeight
+ - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
+ if (availW <= 0 || availH <= 0) return;
+
+ logEl.style.transform = 'none';
+ var w = logEl.scrollWidth;
+ var h = logEl.scrollHeight;
+ if (!w || !h) return;
+
+ var scale = Math.min(1, availW / w, availH / h);
+ logEl.style.transform = scale < 1 ? 'scale(' + scale + ')' : 'none';
+ }
+
+ function scheduleFit() {
+ if (fitRaf) return;
+ fitRaf = window.requestAnimationFrame(fitBootLog);
+ }
+
+ window.addEventListener('resize', scheduleFit);
+ window.addEventListener('orientationchange', scheduleFit);
+
+ var OPENBSD_WORDMARK =
+ ' ____ ____ _____ ____' + '\n' +
+ ' / __ \\____ ___ ____ / __ ) ___// __ \\' + '\n' +
+ ' / / / / __ \\/ _ \\/ __ \\/ __ \\__ \\/ / / /' + '\n' +
+ '/ /_/ / /_/ / __/ / / / /_/ /__/ / /_/ /' + '\n' +
+ '\\____/ .___/\\___/_/ /_/_____/____/_____/' + '\n' +
+ ' /_/';
+
+ var PUFFY_ASCII =
+ ' _' + '\n' +
+ ' (_)' + '\n' +
+ ' | .' + '\n' +
+ ' . |L /| . _' + '\n' +
+ ' _ . |\\ _| \\--+._/| . (_)' + '\n' +
+ ' / ||\\| Y J ) / |/| ./' + '\n' +
+ ' J |)\'( | ` F`.\'/ _' + '\n' +
+ ' -<| F __ .-< (_)' + '\n' +
+ ' | / .-\'. `. /-. L___' + '\n' +
+ ' J \\ < \\ | | O\\|.-\' _' + '\n' +
+ ' _J \\ .- \\/ O | | \\ |F (_)' + '\n' +
+ ' \'-F -<_. \\ .-\' `-\' L__' + '\n' +
+ '__J _ _. >-\' )._. |-\'' + '\n' +
+ ' `-|.\' /_. \\_| F' + '\n' +
+ ' /.- . _.<' + '\n' +
+ ' /\' /.\' .\' `\\' + '\n' +
+ ' /L /\' |/ _.-\'-\\' + '\n' +
+ ' /\'J ___.---\'\\|' + '\n' +
+ ' |\\ .--\' V | `. `' + '\n' +
+ ' |/`. `-. `._)' + '\n' +
+ ' / .-.\\' + '\n' +
+ ' \\ ( `\\' + '\n' +
+ ' `.\\';
+
+ var avatar = el('img', 'boot-avatar');
+ avatar.src = '/assets/img/lain.png';
+ avatar.alt = '';
+ avatar.width = 96;
+ avatar.height = 96;
+
+ /* Lain sits directly above Puffy's bubble column (the `(_)` marks on the
+ right of the ASCII), so the bubbles read as rising toward her. */
+ var puffyWrap = el('div', 'boot-puffy-wrap');
+ puffyWrap.appendChild(avatar);
+ puffyWrap.appendChild(el('pre', 'boot-puffy-ascii', PUFFY_ASCII));
+
+ var asciiWrap = el('div', 'boot-ascii-wrap');
+ asciiWrap.appendChild(puffyWrap);
+ asciiWrap.appendChild(el('pre', 'boot-openbsd-wordmark', OPENBSD_WORDMARK));
+ logEl.appendChild(asciiWrap);
+ fitBootLog();
+ /* lain.png may land after first layout and change the log's height. */
+ avatar.addEventListener('load', scheduleFit);
+
+ /* Prefer the version cached by tools/update_openbsd_version.sh (cron pulls
+ openbsd.org). Fallback: OpenBSD ships ~1 May and ~1 Nov, rolling
+ x.9 -> (x+1).0; anchor 7.9 = May 2026 so the banner never goes stale. */
+ function openbsdRelease() {
+ var cached = (typeof window.OPENBSD_RELEASE === 'string')
+ ? window.OPENBSD_RELEASE.trim() : '';
+ if (/^\d+\.\d+$/.test(cached)) return cached;
+
+ var now = new Date();
+ var m = now.getMonth();
+ var halves = (now.getFullYear() - 2026) * 2 + (m >= 10 ? 1 : (m >= 4 ? 0 : -1));
+ var minor = 9 + halves;
+ return (7 + Math.floor(minor / 10)) + '.' + (minor % 10);
+ }
+
+ logEl.appendChild(el('div', 'boot-title',
+ 'OpenBSD ' + openbsdRelease() + ' (GENERIC.MP) #1: sillylaird.ca — amd64'));
+ fitBootLog();
+
+ function finishBoot() {
+ if (fitRaf) window.cancelAnimationFrame(fitRaf);
+ rootStyle.overflow = prevRootOverflow;
+ window.removeEventListener('resize', scheduleFit);
+ window.removeEventListener('orientationchange', scheduleFit);
+ sessionStorage.setItem('startpageBooted', '1');
+ overlay.hidden = true;
+ body.classList.remove('startpage-booting');
+ body.classList.add('startpage-ready');
+ }
+
+ function fadeOut() {
+ overlay.classList.add('boot-fade');
+ window.setTimeout(finishBoot, 380);
+ }
+
+ function showPrompt() {
+ var prompt = el('div', 'boot-login');
+ prompt.appendChild(el('span', 'boot-user', 'sillylaird@Navi'));
+ prompt.appendChild(el('span', null, ':'));
+ prompt.appendChild(el('span', 'boot-path', '~'));
+ prompt.appendChild(el('span', null, '$ '));
+ prompt.appendChild(el('span', 'boot-cursor', '█'));
+ logEl.appendChild(prompt);
+ fitBootLog();
+ window.setTimeout(fadeOut, 420);
+ }
+
+ function typeLogin() {
+ var login = el('div', 'boot-login');
+ var label = el('span', 'boot-dim', 'Navi login: ');
+ var typed = el('span', null, '');
+ var cursor = el('span', 'boot-cursor', '█');
+ login.appendChild(label);
+ login.appendChild(typed);
+ login.appendChild(cursor);
+ logEl.appendChild(login);
+ fitBootLog();
+
+ var name = 'sillylaird';
+ var i = 0;
+ function typeChar() {
+ if (i < name.length) {
+ typed.textContent += name.charAt(i);
+ fitBootLog();
+ i += 1;
+ window.setTimeout(typeChar, 70);
+ return;
+ }
+ cursor.remove();
+ window.setTimeout(function () {
+ logEl.appendChild(el('div', 'boot-login boot-dim',
+ 'Password: '));
+ logEl.appendChild(el('div', 'boot-dim',
+ 'Welcome to OpenBSD: The proactively secure Unix-like operating system.'));
+ fitBootLog();
+ window.setTimeout(showPrompt, 240);
+ }, 180);
+ }
+ window.setTimeout(typeChar, 150);
+ }
+
+ function nextLine() {
+ if (idx >= steps.length) {
+ typeLogin();
+ return;
+ }
+ var line = el('div', 'boot-line');
+ var left = el('span', 'boot-svc', steps[idx]);
+ line.appendChild(left);
+ logEl.appendChild(line);
+ fitBootLog();
+ idx += 1;
+ window.setTimeout(nextLine, idx === 1 ? 110 : 130);
+ }
+
+ window.setTimeout(nextLine, 140);
+})();
+
+/* Comfortable line spacing — synced with site-wide comfort toggle (header ↕) */
+(function () {
+ var KEY = 'siteComfortSpacing_v1';
+ var btn = document.getElementById('comfort-toggle');
+ var body = document.body;
+ if (!btn) return;
+
+ function apply(on) {
+ body.classList.toggle('startpage-comfort', on);
+ body.classList.toggle('comfort-spacing', on);
+ // Keep the header icon button compact (do not replace ↕ with long text).
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ btn.setAttribute('title', on ? 'Comfort spacing: ON' : 'Comfort spacing: OFF');
+ btn.setAttribute('aria-label', on ? 'Comfort spacing on' : 'Comfort spacing off');
+ }
+
+ // Use comfortable spacing unless this browser has explicitly opted out.
+ apply(localStorage.getItem(KEY) !== '0');
+
+ btn.addEventListener('click', function () {
+ var next = !body.classList.contains('startpage-comfort');
+ localStorage.setItem(KEY, next ? '1' : '0');
+ apply(next);
+ });
+})();
+
+/* Konaste / BEMANI PC maintenance status — computed client-side from the fixed
+ e-amusement schedule (shared backend, so both games share windows):
+ - Daily maintenance 05:00–07:00 JST
+ - Extended maintenance 02:00–07:00 JST on the 3rd Tuesday of the month
+ JST is a fixed UTC+9 offset (no DST). Display times use the timezone
+ configured on the viewer's computer/browser. */
+(function () {
+ var root = document.getElementById('konaste-status');
+ if (!root) return;
+
+ var JST_OFFSET_MS = 9 * 3600 * 1000;
+ var DISPLAY_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ // JST wall-clock fields for a real Date.
+ function jstFields(date) {
+ var j = new Date(date.getTime() + JST_OFFSET_MS);
+ return {
+ y: j.getUTCFullYear(),
+ mo: j.getUTCMonth(),
+ d: j.getUTCDate(),
+ dow: j.getUTCDay()
+ };
+ }
+
+ // Real epoch ms for a JST wall-clock instant.
+ function jstToEpoch(y, mo, d, h, m) {
+ return Date.UTC(y, mo, d, h, m) - JST_OFFSET_MS;
+ }
+
+ // 3rd Tuesday => Tuesday (dow 2) with day-of-month 15..21.
+ function isThirdTuesday(f) {
+ return f.dow === 2 && f.d >= 15 && f.d <= 21;
+ }
+
+ // Maintenance window [start, end) in real epoch ms for the JST calendar day
+ // that a given JST-field set falls on. offsetDays shifts that day.
+ function windowFor(baseFields, offsetDays) {
+ var day = new Date(Date.UTC(baseFields.y, baseFields.mo, baseFields.d + offsetDays));
+ var f = { y: day.getUTCFullYear(), mo: day.getUTCMonth(), d: day.getUTCDate(), dow: day.getUTCDay() };
+ var startHour = isThirdTuesday(f) ? 2 : 5;
+ return {
+ start: jstToEpoch(f.y, f.mo, f.d, startHour, 0),
+ end: jstToEpoch(f.y, f.mo, f.d, 7, 0),
+ extended: isThirdTuesday(f)
+ };
+ }
+
+ function pad2(n) { return n < 10 ? '0' + n : '' + n; }
+
+ function fmtLocal(epoch) {
+ var d = new Date(epoch);
+ try {
+ var formatted = new Intl.DateTimeFormat(navigator.language || undefined, {
+ weekday: 'short', hour: '2-digit', minute: '2-digit',
+ timeZone: DISPLAY_TIME_ZONE
+ }).format(d);
+ return formatted;
+ } catch (e) {
+ return d.toLocaleString();
+ }
+ }
+
+ function fmtCountdown(ms) {
+ var total = Math.max(0, Math.floor(ms / 60000));
+ var h = Math.floor(total / 60);
+ var m = total % 60;
+ if (h > 0) return h + 'h ' + m + 'm';
+ return m + 'm';
+ }
+
+ // Determine status at the current moment.
+ function compute(now) {
+ var f = jstFields(now);
+ var t = now.getTime();
+ // Today's window plus the previous day's (guards a window that we might
+ // still be inside just after JST midnight — none currently cross it, but
+ // this keeps the logic correct if hours ever change).
+ var candidates = [windowFor(f, -1), windowFor(f, 0)];
+ for (var i = 0; i < candidates.length; i++) {
+ var w = candidates[i];
+ if (t >= w.start && t < w.end) {
+ return { online: false, window: w, until: w.end };
+ }
+ }
+ // Online: find the next upcoming window start.
+ var next = null;
+ for (var d = 0; d <= 40; d++) {
+ var w2 = windowFor(f, d);
+ if (w2.start > t) { next = w2; break; }
+ }
+ return { online: true, next: next };
+ }
+
+ // Live local clock — hour / minute / second kept in separate fields, ticks in
+ // real time using the computer's connected timezone.
+ function renderClock(now) {
+ var el = document.getElementById('konaste-clock');
+ if (!el) return;
+ var h = el.querySelector('[data-clock="h"]');
+ var m = el.querySelector('[data-clock="m"]');
+ var s = el.querySelector('[data-clock="s"]');
+ var ap = el.querySelector('[data-clock="ap"]');
+ var parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: DISPLAY_TIME_ZONE,
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true
+ }).formatToParts(now);
+ var clockParts = {};
+ for (var i = 0; i < parts.length; i++) clockParts[parts[i].type] = parts[i].value;
+ if (h) h.textContent = clockParts.hour || '--';
+ if (m) m.textContent = clockParts.minute || '--';
+ if (s) s.textContent = clockParts.second || '--';
+ if (ap) ap.textContent = clockParts.dayPeriod || '';
+ var tz = root.querySelector('[data-clock="tz"]');
+ if (tz) tz.textContent = '';
+ }
+
+ // Single shared online/offline line — pop'n Lively and GITADORA use the same
+ // e-amusement maintenance window, so one status covers both.
+ function renderStatus(now) {
+ var state = compute(now);
+ var row = root.querySelector('.konaste-game');
+ if (!row) return;
+ var dot = row.querySelector('[data-role="dot"]');
+ var text = row.querySelector('[data-role="text"]');
+ var cd = row.querySelector('[data-role="countdown"]');
+
+ dot.className = 'konaste-dot';
+ cd.textContent = '';
+
+ if (!state.online) {
+ dot.classList.add('is-maintenance');
+ text.textContent = (state.window.extended ? 'Extended maintenance' : 'Under maintenance') +
+ '\nStarted: ' + fmtLocal(state.window.start) +
+ '\nEnds: ' + fmtLocal(state.window.end);
+ cd.textContent = 'Remaining: ' + fmtCountdown(state.until - now.getTime());
+ } else {
+ dot.classList.add('is-online');
+ text.textContent = 'Online • play now';
+ if (state.next) {
+ text.textContent += '\nNext maintenance: ' + fmtLocal(state.next.start) +
+ ' to ' + fmtLocal(state.next.end);
+ cd.textContent = 'Starts in: ' + fmtCountdown(state.next.start - now.getTime());
+ }
+ }
+ }
+
+ function tick() {
+ var now = new Date();
+ renderClock(now);
+ renderStatus(now);
+ }
+
+ tick();
+ setInterval(tick, 1000);
+ document.addEventListener('visibilitychange', function () {
+ if (!document.hidden) tick();
+ });
+})();
diff --git a/startpage/tf2.png b/startpage/tf2.png
new file mode 100644
index 0000000..bd3794a
--- /dev/null
+++ b/startpage/tf2.png
Binary files differ
diff --git a/startpage/tilde.png b/startpage/tilde.png
new file mode 100644
index 0000000..20e1184
--- /dev/null
+++ b/startpage/tilde.png
Binary files differ
diff --git a/startpage/videogame.jpg b/startpage/videogame.jpg
new file mode 100644
index 0000000..1d9b1d2
--- /dev/null
+++ b/startpage/videogame.jpg
Binary files differ
diff --git a/startpage/videogame.png b/startpage/videogame.png
new file mode 100644
index 0000000..c8e3020
--- /dev/null
+++ b/startpage/videogame.png
Binary files differ
diff --git a/startpage/wallpaper.jpg b/startpage/wallpaper.jpg
new file mode 100644
index 0000000..d67ac74
--- /dev/null
+++ b/startpage/wallpaper.jpg
Binary files differ