(function () { "use strict"; const STORAGE_KEY = "zxh_house_of_pages_v1"; const MAIN_ROOM_IDS = Object.freeze([ "foyer", "library", "study", "kitchen", "workshop", "playroom", "attic", "garden" ]); const ROOMS = Object.freeze({ foyer: room({ title: "Foyer", eyebrow: "Arrivals", description: "The front door opens onto the newest corners of the site.", caption: "A brass key waits in a blue bowl beside the door.", curated: [ ["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"] ], include: ["/recently-updated.html", "/home/contact.html"] }), library: room({ title: "Library", eyebrow: "Knowledge", description: "Notes and lasting ideas gather here, arranged less strictly than the shelves suggest.", caption: "One book has been returned with a pressed leaf instead of a bookmark.", curated: [ ["All posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"] ], include: ["/posts/"], exclude: ["/posts/career/"] }), study: room({ title: "Study", eyebrow: "Work", description: "Engineering notes, professional lessons, and active competencies cover the desk.", caption: "The lamp is still warm; somebody meant to come back after tea.", curated: [ ["Career library", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"] ], include: ["/posts/career/"] }), kitchen: room({ title: "Kitchen", eyebrow: "Daily life", description: "Weekly reviews and ordinary days stay close to the kettle.", caption: "A shopping list shares the table with a thought worth keeping.", curated: [ ["All blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blog introduction", "/blogs/blogs-intro.html"] ], include: ["/blogs/"] }), workshop: room({ title: "Workshop", eyebrow: "Living systems", description: "Trackers, services, plans, and practical machinery keep the house running.", caption: "Every drawer is labelled except the one containing all the labels.", curated: [ ["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"] ], include: ["/home/services.html", "/home/wird-tracker.html", "/home/countdown.html", "/home/backlog.html"] }), playroom: room({ title: "Playroom", eyebrow: "Experiments", description: "Games, generators, puzzles, and stranger little mechanisms wait on the floor.", caption: "A wooden moon rolls beneath the cabinet whenever nobody is looking.", curated: [ ["Play hub", "/play/play.html"], ["Rescue Princess Lima", "/play/rpg.html"], ["The Rain Index", "/play/the-rain-index.html"] ], include: ["/play/"], exclude: ["/play/house.html", "/play/play.html"] }), attic: room({ title: "Attic", eyebrow: "Keepsakes", description: "Personal fragments and older writing rest beneath the roof beams.", caption: "The smallest box is labelled: things that became important later.", curated: [ ["Lima archive", "/lima/index.html"], ["Blog archive", "/blogs/blogs-list.html"], ["Memory Cabinet", "/play/memory.html"] ], include: ["/lima/", "/blogs/2025/"] }), garden: room({ title: "Garden", eyebrow: "Notes left outside", description: "Loose notes, paths between topics, and recently tended pages grow beyond the back step.", caption: "Someone has tied a question to the pear tree with green thread.", curated: [ ["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"] ], include: ["/tags/", "/home/notes.html", "/home/categories.html"] }), archive: room({ title: "Archive Room", eyebrow: "The Ninth Door", description: "The house keeps one room for the shape of itself: maps, labels, plans, and unfinished intentions.", caption: "On the inside of the door: A house is an index that learned how to wait.", curated: [ ["Sitemap", "/sitemap.html"], ["Categories", "/home/categories.html"], ["Backlog", "/home/backlog.html"] ], include: ["/sitemap.html", "/home/categories.html", "/home/backlog.html"] }) }); function room(config) { return Object.freeze(Object.assign({ exclude: [] }, config, { curated: Object.freeze(config.curated.map((link) => Object.freeze(link.slice()))), include: Object.freeze(config.include.slice()), exclude: Object.freeze((config.exclude || []).slice()) })); } document.addEventListener("DOMContentLoaded", () => { const root = document.querySelector('.house-root[data-play-page="house"]'); if (!root) return; initHouse(root); }); function initHouse(root) { const roomButtons = Array.from(root.querySelectorAll("[data-house-room]")); const mainButtons = roomButtons.filter((button) => MAIN_ROOM_IDS.includes(button.dataset.houseRoom)); const secretButton = root.querySelector(".house-secret-door"); const panel = root.querySelector("[data-house-panel]"); const status = root.querySelector("[data-house-status]"); const reset = root.querySelector("[data-house-reset]"); const state = loadState(); let pages = []; let indexStatus = "loading"; root.classList.add("is-enhanced"); state.visited = state.visited.filter((id) => MAIN_ROOM_IDS.includes(id)); if (state.visited.length === MAIN_ROOM_IDS.length) state.secretUnlocked = true; renderSecret(); roomButtons.forEach((button) => { button.addEventListener("click", () => selectRoom(button.dataset.houseRoom, true)); button.addEventListener("keydown", (event) => moveRoomFocus(event, button)); }); reset.addEventListener("click", () => { state.visited = []; state.secretUnlocked = false; saveState(state); roomButtons.forEach((button) => button.classList.remove("is-visited")); renderSecret(); selectRoom("foyer", false); status.textContent = "The house has forgotten this visit. Choose a room to begin again."; }); fetch("/search-index.json", { credentials: "same-origin" }) .then((response) => { if (!response.ok) throw new Error(`Search index returned ${response.status}`); return response.json(); }) .then((index) => { pages = flattenIndex(index); indexStatus = "ready"; renderRoom(root.querySelector("[data-house-room][aria-selected='true']")?.dataset.houseRoom || "foyer"); }) .catch((error) => { console.warn("House shelves could not be catalogued", error); indexStatus = "failed"; renderRoom(root.querySelector("[data-house-room][aria-selected='true']")?.dataset.houseRoom || "foyer"); }); function selectRoom(id, countVisit) { const config = ROOMS[id]; const selected = roomButtons.find((button) => button.dataset.houseRoom === id); if (!config || !selected) return; roomButtons.forEach((button) => { const active = button === selected; button.classList.toggle("is-selected", active); button.setAttribute("aria-selected", String(active)); button.tabIndex = active ? 0 : -1; }); if (countVisit && MAIN_ROOM_IDS.includes(id) && !state.visited.includes(id)) { state.visited.push(id); selected.classList.add("is-visited"); if (state.visited.length === MAIN_ROOM_IDS.length) state.secretUnlocked = true; saveState(state); renderSecret(); } panel.setAttribute("aria-labelledby", selected.id); root.dataset.houseActive = id; renderRoom(id); updateStatus(); } function renderRoom(id) { const config = ROOMS[id]; if (!config) return; root.querySelector("[data-house-eyebrow]").textContent = config.eyebrow; root.querySelector("[data-house-title]").textContent = config.title; root.querySelector("[data-house-description]").textContent = config.description; root.querySelector("[data-house-caption]").textContent = config.caption; renderLinks(root.querySelector("[data-house-curated]"), config.curated.map(([title, href]) => ({ title, href }))); const dynamicList = root.querySelector("[data-house-dynamic]"); if (indexStatus === "loading") { renderEmpty(dynamicList, "The shelves are being catalogued…"); return; } if (indexStatus === "failed") { renderEmpty(dynamicList, "The shelves could not be catalogued today."); return; } const curatedPaths = new Set(config.curated.map((link) => normalizePath(link[1]))); const matches = pages .filter((page) => matchesRoom(page.path, config)) .filter((page) => !curatedPaths.has(page.path)) .filter((page) => !isGeneratedListing(page.path)) .sort((a, b) => a.path.localeCompare(b.path)) .slice(0, 4); if (matches.length === 0) renderEmpty(dynamicList, "Nothing else is resting here yet."); else renderLinks(dynamicList, matches.map((page) => ({ title: humanizeFilename(page.name), href: page.path }))); } function renderSecret() { secretButton.hidden = !state.secretUnlocked; mainButtons.forEach((button) => button.classList.toggle("is-visited", state.visited.includes(button.dataset.houseRoom))); root.classList.toggle("has-secret", state.secretUnlocked); } function updateStatus() { if (state.secretUnlocked) { status.textContent = "All eight rooms remember you. Somewhere nearby, a ninth door has appeared."; } else { const remaining = MAIN_ROOM_IDS.length - state.visited.length; status.textContent = `${state.visited.length} of ${MAIN_ROOM_IDS.length} rooms visited · ${remaining} ${remaining === 1 ? "room" : "rooms"} still unlit.`; } } function moveRoomFocus(event, current) { if (!MAIN_ROOM_IDS.includes(current.dataset.houseRoom)) return; const movement = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]; if (!movement) return; event.preventDefault(); const index = mainButtons.indexOf(current); const next = mainButtons[(index + movement + mainButtons.length) % mainButtons.length]; next.focus(); selectRoom(next.dataset.houseRoom, true); } selectRoom("foyer", true); } function flattenIndex(root) { const found = []; const seen = new Set(); function visit(node) { if (!node || typeof node !== "object") return; if (node.type === "file" && typeof node.url === "string") { const path = normalizePath(node.url); if (path.endsWith(".html") && !seen.has(path)) { seen.add(path); found.push({ name: node.name || path.split("/").pop(), path }); } } if (Array.isArray(node.children)) node.children.forEach(visit); } visit(root); return found; } function matchesRoom(path, config) { const included = config.include.some((prefix) => path.startsWith(normalizePath(prefix))); const excluded = config.exclude.some((prefix) => path.startsWith(normalizePath(prefix))); return included && !excluded && path !== "/play/house.html"; } function isGeneratedListing(path) { const filename = path.split("/").pop() || ""; return filename === "index.html" || filename.endsWith("-list.html") || filename.endsWith("-intro.html"); } function humanizeFilename(name) { return String(name || "Untitled page") .replace(/\.html$/i, "") .replace(/^\d{2}-\d{2}-/, "") .replace(/[._-]+/g, " ") .replace(/\b\w/g, (letter) => letter.toUpperCase()) .trim(); } function normalizePath(path) { const clean = String(path || "").replace(/\\/g, "/").split(/[?#]/)[0]; return clean.startsWith("/") ? clean : `/${clean}`; } function renderLinks(list, links) { list.replaceChildren(...links.map(({ title, href }) => { const item = document.createElement("li"); const anchor = document.createElement("a"); anchor.href = href; anchor.textContent = title; item.appendChild(anchor); return item; })); } function renderEmpty(list, message) { const item = document.createElement("li"); item.className = "house-empty"; item.textContent = message; list.replaceChildren(item); } function loadState() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); return { visited: Array.isArray(saved.visited) ? saved.visited.slice() : [], secretUnlocked: saved.secretUnlocked === true }; } catch (_error) { return { visited: [], secretUnlocked: false }; } } function saveState(state) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ visited: state.visited, secretUnlocked: state.secretUnlocked })); } catch (_error) { // The house remains navigable when storage is unavailable. } } })();