aboutsummaryrefslogtreecommitdiffstats
path: root/startpage/startpage.js
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/startpage.js
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rw-r--r--startpage/startpage.js824
1 files changed, 824 insertions, 0 deletions
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();
+ });
+})();