zq/personalise #1
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
|||||||
.venv/
|
.venv/
|
||||||
output/
|
output/
|
||||||
.org-timestamps/
|
.org-timestamps/
|
||||||
backup/
|
backups/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*~
|
*~
|
||||||
.syncthing*.tmp
|
.syncthing*.tmp
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all clean norm test author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
|
.PHONY: all clean norm test test-elisp author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
|
||||||
|
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PY := $(VENV)/bin/python
|
PY := $(VENV)/bin/python
|
||||||
@@ -25,6 +25,9 @@ search: $(VENV)
|
|||||||
test:
|
test:
|
||||||
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test
|
||||||
|
|
||||||
|
test-elisp:
|
||||||
|
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
|
||||||
|
|
||||||
$(VENV):
|
$(VENV):
|
||||||
@echo "Generating virtual environment"
|
@echo "Generating virtual environment"
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
@@ -80,6 +83,7 @@ help:
|
|||||||
@echo " make clean-venv - Remove venv directory"
|
@echo " make clean-venv - Remove venv directory"
|
||||||
@echo " make norm - Move backup files"
|
@echo " make norm - Move backup files"
|
||||||
@echo " make test - Run authoring server tests"
|
@echo " make test - Run authoring server tests"
|
||||||
|
@echo " make test-elisp - Run Emacs Lisp build tests"
|
||||||
@echo " make author - Install and start the authoring UI service"
|
@echo " make author - Install and start the authoring UI service"
|
||||||
@echo " make author-install - Install and enable the authoring UI service"
|
@echo " make author-install - Install and enable the authoring UI service"
|
||||||
@echo " make author-stop - Stop the authoring UI service"
|
@echo " make author-stop - Stop the authoring UI service"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This repository contains code for the website https://zainezq.com, which serves
|
|||||||
|
|
||||||
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published.
|
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published.
|
||||||
|
|
||||||
|
Asset structure and frontend boundaries are documented in `docs/assets-architecture.md`.
|
||||||
|
|
||||||
## Local authoring UI
|
## Local authoring UI
|
||||||
|
|
||||||
The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`.
|
The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
Binary file not shown.
32
assets/scripts/application/theme-switcher.js
Normal file
32
assets/scripts/application/theme-switcher.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* Function for setting the theme */
|
||||||
|
(function(){
|
||||||
|
const root = document.documentElement;
|
||||||
|
const storageKey = "theme";
|
||||||
|
const themes = ["light", "dark", "dark-academia"];
|
||||||
|
const labels = {
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"dark-academia": "Academia"
|
||||||
|
};
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (themes.includes(saved)) {
|
||||||
|
root.setAttribute("data-theme", saved);
|
||||||
|
}
|
||||||
|
const btn = document.getElementById("theme-toggle");
|
||||||
|
if (!btn) return;
|
||||||
|
const updateButton = () => {
|
||||||
|
const current = root.getAttribute("data-theme");
|
||||||
|
const label = labels[current] || "Auto";
|
||||||
|
btn.textContent = `Theme: ${label}`;
|
||||||
|
btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`);
|
||||||
|
};
|
||||||
|
updateButton();
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const current = root.getAttribute("data-theme");
|
||||||
|
const index = themes.indexOf(current);
|
||||||
|
const target = themes[index === -1 ? 1 : (index + 1) % themes.length];
|
||||||
|
root.setAttribute("data-theme", target);
|
||||||
|
localStorage.setItem(storageKey, target);
|
||||||
|
updateButton();
|
||||||
|
});
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
8
assets/scripts/bigger-picture.min.js
vendored
8
assets/scripts/bigger-picture.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,196 +1,7 @@
|
|||||||
let pageSlug = null;
|
(function () {
|
||||||
|
"use strict";
|
||||||
/* -----------------------------
|
var script = document.createElement("script");
|
||||||
* DOM builders
|
script.src = "/assets/scripts/features/comments.js";
|
||||||
* ----------------------------- */
|
script.defer = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}());
|
||||||
function showReplyForm(container, parentId) {
|
|
||||||
if (container.querySelector(".reply-form")) return;
|
|
||||||
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.className = "reply-form";
|
|
||||||
|
|
||||||
form.innerHTML = `
|
|
||||||
<input type="text" name="author" placeholder="Your name (optional)">
|
|
||||||
<textarea name="content" required placeholder="Write a reply..."></textarea>
|
|
||||||
<button type="submit">Post reply</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
form.addEventListener("submit", async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const author = form.author.value.trim();
|
|
||||||
const content = form.content.value.trim();
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const ok = await postComment(author, content, parentId);
|
|
||||||
if (ok) loadComments();
|
|
||||||
else alert("Failed to post reply");
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createComment(comment) {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "comment";
|
|
||||||
wrapper.dataset.id = comment.id;
|
|
||||||
wrapper.id = `comment-${comment.id}`;
|
|
||||||
|
|
||||||
const author = document.createElement("strong");
|
|
||||||
author.textContent = comment.author || "Anonymous";
|
|
||||||
|
|
||||||
const body = document.createElement("p");
|
|
||||||
body.textContent = comment.content;
|
|
||||||
|
|
||||||
const time = document.createElement("time");
|
|
||||||
time.className = "comment-date";
|
|
||||||
time.dateTime = comment.created_at;
|
|
||||||
time.textContent = new Date(comment.created_at).toLocaleString();
|
|
||||||
|
|
||||||
const replyBtn = document.createElement("button");
|
|
||||||
replyBtn.className = "comment-reply";
|
|
||||||
replyBtn.textContent = "Reply";
|
|
||||||
replyBtn.onclick = () =>
|
|
||||||
showReplyForm(wrapper, Number(comment.id));
|
|
||||||
|
|
||||||
wrapper.append(author, body, time, replyBtn);
|
|
||||||
|
|
||||||
if (comment.children?.length) {
|
|
||||||
const replies = document.createElement("div");
|
|
||||||
replies.className = "comment-children";
|
|
||||||
|
|
||||||
comment.children.forEach(child => {
|
|
||||||
replies.appendChild(createComment(child));
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.appendChild(replies);
|
|
||||||
}
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function buildCommentTree(comments) {
|
|
||||||
const byId = {};
|
|
||||||
const roots = [];
|
|
||||||
|
|
||||||
comments.forEach(comment => {
|
|
||||||
comment.children = [];
|
|
||||||
byId[comment.id] = comment;
|
|
||||||
});
|
|
||||||
|
|
||||||
comments.forEach(comment => {
|
|
||||||
if (comment.parent_id) {
|
|
||||||
const parent = byId[comment.parent_id];
|
|
||||||
if (parent) parent.children.push(comment);
|
|
||||||
} else {
|
|
||||||
roots.push(comment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Rendering
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
function renderComments(comments) {
|
|
||||||
const list = document.getElementById("comments-list");
|
|
||||||
list.innerHTML = "";
|
|
||||||
|
|
||||||
if (!comments.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "comments-empty";
|
|
||||||
empty.textContent = "No comments yet.";
|
|
||||||
list.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = buildCommentTree(comments);
|
|
||||||
tree.forEach(comment => list.appendChild(createComment(comment)));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* API
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
async function fetchComments() {
|
|
||||||
const res = await fetch(`/api/comments/${pageSlug}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Failed to fetch comments");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postComment(author, content, parentId = null) {
|
|
||||||
const res = await fetch("/api/comments", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
page_slug: pageSlug,
|
|
||||||
author: author || null,
|
|
||||||
content,
|
|
||||||
parent_id: parentId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Form handling
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
function wireCommentForm() {
|
|
||||||
const form = document.getElementById("comment-form");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const authorInput = form.querySelector("input[name='author']");
|
|
||||||
const textarea = form.querySelector("textarea[name='content']");
|
|
||||||
|
|
||||||
const author = authorInput.value.trim();
|
|
||||||
const content = textarea.value.trim();
|
|
||||||
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const ok = await postComment(author, content);
|
|
||||||
if (ok) {
|
|
||||||
textarea.value = "";
|
|
||||||
loadComments();
|
|
||||||
} else {
|
|
||||||
alert("Failed to post comment");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Entry point
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
async function loadComments() {
|
|
||||||
const comments = await fetchComments();
|
|
||||||
renderComments(comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initComments() {
|
|
||||||
const section = document.getElementById("comments");
|
|
||||||
if (!section) return;
|
|
||||||
|
|
||||||
pageSlug = section.dataset.slug;
|
|
||||||
|
|
||||||
wireCommentForm();
|
|
||||||
loadComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
initComments();
|
|
||||||
|
|||||||
@@ -1,240 +1,7 @@
|
|||||||
const STATES = [
|
(function () {
|
||||||
{ key: "completed", label: "Completed" },
|
"use strict";
|
||||||
{ key: "manager_review", label: "Manager Review" },
|
var script = document.createElement("script");
|
||||||
{ key: "in_progress", label: "In Progress" },
|
script.src = "/assets/scripts/pages/competency-status-board.js";
|
||||||
{ key: "not_started", label: "Not Started" },
|
script.defer = true;
|
||||||
{ key: "comments", label: "Comments" },
|
document.head.appendChild(script);
|
||||||
];
|
}());
|
||||||
|
|
||||||
const LEVELS = [
|
|
||||||
{ key: "graduate", label: "Graduate" },
|
|
||||||
{ key: "engineer", label: "Engineer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let currentLevel = LEVELS[0].key;
|
|
||||||
|
|
||||||
function isMobile() {
|
|
||||||
return window.matchMedia("(max-width: 600px)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLevelTabs() {
|
|
||||||
const board = document.getElementById("kanban-board");
|
|
||||||
if (!board || document.getElementById("level-tabs")) return;
|
|
||||||
|
|
||||||
const nav = document.createElement("div");
|
|
||||||
nav.id = "level-tabs";
|
|
||||||
|
|
||||||
LEVELS.forEach(({ key, label }) => {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.dataset.level = key;
|
|
||||||
btn.className = "level-tab" + (key === currentLevel ? " active" : "");
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
currentLevel = key;
|
|
||||||
document.querySelectorAll(".level-tab").forEach(b =>
|
|
||||||
b.classList.toggle("active", b.dataset.level === key)
|
|
||||||
);
|
|
||||||
loadBoard();
|
|
||||||
});
|
|
||||||
nav.appendChild(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
board.insertAdjacentElement("beforebegin", nav);
|
|
||||||
|
|
||||||
if (!document.getElementById("level-tab-styles")) {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = "level-tab-styles";
|
|
||||||
style.textContent = `
|
|
||||||
#level-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
.level-tab {
|
|
||||||
padding: 0.4em 1.2em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
.level-tab.active {
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #333;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateMobileControls(items) {
|
|
||||||
if (!isMobile()) return;
|
|
||||||
|
|
||||||
const panel = document.getElementById("mobile-move-panel");
|
|
||||||
const itemSelect = document.getElementById("move-item");
|
|
||||||
const fromSelect = document.getElementById("move-from");
|
|
||||||
const toSelect = document.getElementById("move-to");
|
|
||||||
|
|
||||||
itemSelect.innerHTML = "<option value=''>Select competency</option>";
|
|
||||||
fromSelect.innerHTML = "<option value=''>From</option>";
|
|
||||||
toSelect.innerHTML = "<option value=''>To</option>";
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = item.id;
|
|
||||||
opt.textContent = item.title;
|
|
||||||
opt.dataset.state = item.state;
|
|
||||||
itemSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
|
|
||||||
STATES.forEach((s) => {
|
|
||||||
fromSelect.appendChild(new Option(s.label, s.key));
|
|
||||||
toSelect.appendChild(new Option(s.label, s.key));
|
|
||||||
});
|
|
||||||
|
|
||||||
itemSelect.onchange = () => {
|
|
||||||
const selected = itemSelect.selectedOptions[0];
|
|
||||||
if (selected?.dataset.state) {
|
|
||||||
fromSelect.value = selected.dataset.state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveConfirmBtn = document.getElementById("move-confirm");
|
|
||||||
if (moveConfirmBtn) {
|
|
||||||
moveConfirmBtn.addEventListener("click", async () => {
|
|
||||||
const itemId = document.getElementById("move-item").value;
|
|
||||||
const from = document.getElementById("move-from").value;
|
|
||||||
const to = document.getElementById("move-to").value;
|
|
||||||
|
|
||||||
if (!itemId || !to) {
|
|
||||||
alert("Select an item and target column");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (from === to) {
|
|
||||||
alert("Item is already in that column");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ state: to }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
loadBoard();
|
|
||||||
} else {
|
|
||||||
alert("Failed to move competency");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let draggedItemId = null;
|
|
||||||
|
|
||||||
function countByState(items) {
|
|
||||||
return items.reduce((acc, item) => {
|
|
||||||
acc[item.state] = (acc[item.state] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateProgress(items) {
|
|
||||||
const total = items.length;
|
|
||||||
const completed = items.filter(i => i.state === "completed").length;
|
|
||||||
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
|
||||||
|
|
||||||
const fill = document.getElementById("progress-fill");
|
|
||||||
const label = document.getElementById("progress-label");
|
|
||||||
|
|
||||||
if (fill) fill.style.width = `${percent}%`;
|
|
||||||
if (label) label.textContent = `${percent}% completed`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCard(item) {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "kanban-card";
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.id = item.id;
|
|
||||||
card.textContent = item.title;
|
|
||||||
|
|
||||||
card.addEventListener("dragstart", () => {
|
|
||||||
draggedItemId = item.id;
|
|
||||||
card.classList.add("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener("dragend", () => {
|
|
||||||
draggedItemId = null;
|
|
||||||
card.classList.remove("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createColumn(state, items, counts) {
|
|
||||||
const col = document.createElement("div");
|
|
||||||
col.className = "kanban-column";
|
|
||||||
col.dataset.state = state.key;
|
|
||||||
|
|
||||||
const header = document.createElement("h3");
|
|
||||||
header.innerHTML = `
|
|
||||||
<span class="kanban-title">${state.label}</span>
|
|
||||||
<span class="kanban-count">${counts[state.key] || 0}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = document.createElement("div");
|
|
||||||
list.className = "kanban-list";
|
|
||||||
|
|
||||||
list.addEventListener("dragover", (e) => e.preventDefault());
|
|
||||||
|
|
||||||
list.addEventListener("drop", async () => {
|
|
||||||
if (!draggedItemId) return;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ state: state.key }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
loadBoard();
|
|
||||||
} else {
|
|
||||||
alert("Failed to update state");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
items
|
|
||||||
.filter((i) => i.state === state.key)
|
|
||||||
.forEach((i) => list.appendChild(createCard(i)));
|
|
||||||
|
|
||||||
col.appendChild(header);
|
|
||||||
col.appendChild(list);
|
|
||||||
|
|
||||||
return col;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBoard(items) {
|
|
||||||
const board = document.getElementById("kanban-board");
|
|
||||||
if (!board) return;
|
|
||||||
board.innerHTML = "";
|
|
||||||
|
|
||||||
const counts = countByState(items);
|
|
||||||
|
|
||||||
STATES.forEach((state) => {
|
|
||||||
board.appendChild(createColumn(state, items, counts));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBoard() {
|
|
||||||
const res = await fetch(`/api/competencies/items?group=${currentLevel}`);
|
|
||||||
const items = await res.json();
|
|
||||||
|
|
||||||
updateProgress(items);
|
|
||||||
renderBoard(items);
|
|
||||||
populateMobileControls(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLevelTabs();
|
|
||||||
loadBoard();
|
|
||||||
|
|||||||
196
assets/scripts/features/comments.js
Normal file
196
assets/scripts/features/comments.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
let pageSlug = null;
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* DOM builders
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
|
||||||
|
function showReplyForm(container, parentId) {
|
||||||
|
if (container.querySelector(".reply-form")) return;
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "reply-form";
|
||||||
|
|
||||||
|
form.innerHTML = `
|
||||||
|
<input type="text" name="author" placeholder="Your name (optional)">
|
||||||
|
<textarea name="content" required placeholder="Write a reply..."></textarea>
|
||||||
|
<button type="submit">Post reply</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
form.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const author = form.author.value.trim();
|
||||||
|
const content = form.content.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const ok = await postComment(author, content, parentId);
|
||||||
|
if (ok) loadComments();
|
||||||
|
else alert("Failed to post reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComment(comment) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "comment";
|
||||||
|
wrapper.dataset.id = comment.id;
|
||||||
|
wrapper.id = `comment-${comment.id}`;
|
||||||
|
|
||||||
|
const author = document.createElement("strong");
|
||||||
|
author.textContent = comment.author || "Anonymous";
|
||||||
|
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.textContent = comment.content;
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.className = "comment-date";
|
||||||
|
time.dateTime = comment.created_at;
|
||||||
|
time.textContent = new Date(comment.created_at).toLocaleString();
|
||||||
|
|
||||||
|
const replyBtn = document.createElement("button");
|
||||||
|
replyBtn.className = "comment-reply";
|
||||||
|
replyBtn.textContent = "Reply";
|
||||||
|
replyBtn.onclick = () =>
|
||||||
|
showReplyForm(wrapper, Number(comment.id));
|
||||||
|
|
||||||
|
wrapper.append(author, body, time, replyBtn);
|
||||||
|
|
||||||
|
if (comment.children?.length) {
|
||||||
|
const replies = document.createElement("div");
|
||||||
|
replies.className = "comment-children";
|
||||||
|
|
||||||
|
comment.children.forEach(child => {
|
||||||
|
replies.appendChild(createComment(child));
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(replies);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function buildCommentTree(comments) {
|
||||||
|
const byId = {};
|
||||||
|
const roots = [];
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
comment.children = [];
|
||||||
|
byId[comment.id] = comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.parent_id) {
|
||||||
|
const parent = byId[comment.parent_id];
|
||||||
|
if (parent) parent.children.push(comment);
|
||||||
|
} else {
|
||||||
|
roots.push(comment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Rendering
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
function renderComments(comments) {
|
||||||
|
const list = document.getElementById("comments-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
if (!comments.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "comments-empty";
|
||||||
|
empty.textContent = "No comments yet.";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = buildCommentTree(comments);
|
||||||
|
tree.forEach(comment => list.appendChild(createComment(comment)));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* API
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
async function fetchComments() {
|
||||||
|
const res = await fetch(`/api/comments/${pageSlug}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Failed to fetch comments");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment(author, content, parentId = null) {
|
||||||
|
const res = await fetch("/api/comments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_slug: pageSlug,
|
||||||
|
author: author || null,
|
||||||
|
content,
|
||||||
|
parent_id: parentId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Form handling
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
function wireCommentForm() {
|
||||||
|
const form = document.getElementById("comment-form");
|
||||||
|
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const authorInput = form.querySelector("input[name='author']");
|
||||||
|
const textarea = form.querySelector("textarea[name='content']");
|
||||||
|
|
||||||
|
const author = authorInput.value.trim();
|
||||||
|
const content = textarea.value.trim();
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const ok = await postComment(author, content);
|
||||||
|
if (ok) {
|
||||||
|
textarea.value = "";
|
||||||
|
loadComments();
|
||||||
|
} else {
|
||||||
|
alert("Failed to post comment");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Entry point
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
const comments = await fetchComments();
|
||||||
|
renderComments(comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initComments() {
|
||||||
|
const section = document.getElementById("comments");
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
pageSlug = section.dataset.slug;
|
||||||
|
|
||||||
|
wireCommentForm();
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
initComments();
|
||||||
624
assets/scripts/features/hidden-details.js
Normal file
624
assets/scripts/features/hidden-details.js
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hidden site details
|
||||||
|
* -------------------
|
||||||
|
* This file adds small hidden interactions across the site: footer notes,
|
||||||
|
* click targets, search/keyboard secrets, rare messages, and page-specific
|
||||||
|
* behavior for the hidden /play pages.
|
||||||
|
*
|
||||||
|
* How to add your own things:
|
||||||
|
* - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`,
|
||||||
|
* `greetings`, or `lore`.
|
||||||
|
* - Add search-triggered toast messages to `SEARCH_TOASTS`.
|
||||||
|
* - Add search-triggered page redirects to `SEARCH_ROUTES`.
|
||||||
|
* - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`.
|
||||||
|
* - Add a new feature by writing an `addYourFeature(memory)` function and
|
||||||
|
* adding it to the `FEATURES` list at the bottom of the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Storage keys. v1 is read once so old visitors keep their hidden progress.
|
||||||
|
const STORAGE_KEY = "zxh_hidden_details_v2";
|
||||||
|
const OLD_STORAGE_KEY = "zxh_hidden_details_v1";
|
||||||
|
|
||||||
|
// Shared page context. These are captured once so daily random picks stay stable.
|
||||||
|
const now = new Date();
|
||||||
|
const hour = now.getHours();
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const state = readState();
|
||||||
|
// -----------------------------
|
||||||
|
// EDITABLE CONTENT
|
||||||
|
// -----------------------------
|
||||||
|
// Generated by the hidden narrative authoring page.
|
||||||
|
// Friendly source of truth: assets/content/hidden-details.json
|
||||||
|
|
||||||
|
const familyLayers = [];
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
|
||||||
|
const poems = [];
|
||||||
|
|
||||||
|
const greetings = [];
|
||||||
|
|
||||||
|
const nightMessages = [];
|
||||||
|
|
||||||
|
const lore = {
|
||||||
|
"quotes": [],
|
||||||
|
"conversations": [],
|
||||||
|
"journals": [],
|
||||||
|
"warnings": [],
|
||||||
|
"dreams": [],
|
||||||
|
"cassettes": [],
|
||||||
|
"fakeUsers": [],
|
||||||
|
"seasonal": {},
|
||||||
|
"homepageTakeovers": [],
|
||||||
|
"roomLinks": []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exact search text -> toast message.
|
||||||
|
const SEARCH_TOASTS = {};
|
||||||
|
|
||||||
|
// Exact search text -> hidden page route.
|
||||||
|
const SEARCH_ROUTES = {};
|
||||||
|
|
||||||
|
// Short typed phrases. Stored in sessionStorage as a rolling key chain.
|
||||||
|
const KEYBOARD_SECRETS = [];
|
||||||
|
|
||||||
|
// Longer typed phrases and character names.
|
||||||
|
const LONG_KEYBOARD_SECRETS = [];
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// STATE HELPERS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||||
|
} catch (_) {}
|
||||||
|
if (!current.visits) {
|
||||||
|
try {
|
||||||
|
const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}");
|
||||||
|
current = { ...oldState, migratedFromV1: true };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState(next) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(list, salt) {
|
||||||
|
const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`);
|
||||||
|
return list[seed % list.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
h ^= value.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return Math.abs(h >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// UI HELPERS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function hiddenMessageMarkup(message) {
|
||||||
|
return `<span class="hidden-message-text">${escapeHtml(message)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHiddenMessage(el, message) {
|
||||||
|
delete el.dataset.hiddenCharacter;
|
||||||
|
el.innerHTML = hiddenMessageMarkup(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message, duration) {
|
||||||
|
let el = document.querySelector(".hidden-toast");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.className = "hidden-toast";
|
||||||
|
el.setAttribute("role", "status");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
setHiddenMessage(el, message);
|
||||||
|
el.classList.add("is-visible");
|
||||||
|
window.clearTimeout(el._timer);
|
||||||
|
el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectTo(route) {
|
||||||
|
window.location.href = route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSecretAction(secret) {
|
||||||
|
if (secret.route) redirectTo(secret.route);
|
||||||
|
if (secret.message) toast(secret.message);
|
||||||
|
if (secret.song) playTinySong();
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerForSearch(value) {
|
||||||
|
if (value.includes("future")) return 4;
|
||||||
|
if (value.includes("sensei") || value.includes("aphy")) return 3;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberVisit() {
|
||||||
|
const visits = (state.visits || 0) + 1;
|
||||||
|
const seen = Array.isArray(state.seen) ? state.seen : [];
|
||||||
|
const pathCounts = { ...(state.pathCounts || {}) };
|
||||||
|
pathCounts[path] = (pathCounts[path] || 0) + 1;
|
||||||
|
const layerVisits = { ...(state.layerVisits || {}) };
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
visits,
|
||||||
|
lastPath: path,
|
||||||
|
pathCounts,
|
||||||
|
layerVisits,
|
||||||
|
seen: Array.from(new Set([...seen, path])).slice(-100),
|
||||||
|
firstSeen: state.firstSeen || new Date().toISOString(),
|
||||||
|
lastSeen: new Date().toISOString()
|
||||||
|
};
|
||||||
|
writeState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardLayer(layer, reason) {
|
||||||
|
const next = readState();
|
||||||
|
next.layerVisits = { ...(next.layerVisits || {}) };
|
||||||
|
next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1;
|
||||||
|
next.lastLayerReason = reason;
|
||||||
|
writeState(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// GLOBAL FEATURES
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function addFooterNote(memory) {
|
||||||
|
const footer = document.querySelector("footer");
|
||||||
|
if (!footer || !details.length) return;
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "hidden-footer-note";
|
||||||
|
const base = pick(details, "footer");
|
||||||
|
setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base);
|
||||||
|
footer.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHomepageGreeting(memory) {
|
||||||
|
const target = document.querySelector("#db-greeting");
|
||||||
|
if (!target || !greetings.length) return;
|
||||||
|
setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`));
|
||||||
|
const extra = document.createElement("p");
|
||||||
|
extra.className = "hidden-greeting";
|
||||||
|
setHiddenMessage(extra, "");
|
||||||
|
target.closest("div")?.appendChild(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWhispers() {
|
||||||
|
// Event: click one of the tiny "?" marks appended to long paragraphs.
|
||||||
|
const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80);
|
||||||
|
paragraphs.slice(0, 5).forEach((p, index) => {
|
||||||
|
if ((hash(path + index) + index) % 3 !== 0) return;
|
||||||
|
const mark = document.createElement("span");
|
||||||
|
mark.className = "hidden-whisper";
|
||||||
|
mark.tabIndex = 0;
|
||||||
|
mark.textContent = " ?";
|
||||||
|
const source = index % 2 ? poems : details;
|
||||||
|
if (!source.length) return;
|
||||||
|
mark.title = pick(source, `whisper-${index}`);
|
||||||
|
mark.addEventListener("click", () => {
|
||||||
|
awardLayer(index % 2 ? 2 : 1, "paragraph whisper");
|
||||||
|
toast(mark.title);
|
||||||
|
});
|
||||||
|
p.appendChild(mark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCornerObject() {
|
||||||
|
// Event: click the small fixed button in the bottom-left corner.
|
||||||
|
const object = document.createElement("button");
|
||||||
|
object.className = "hidden-corner-object";
|
||||||
|
object.type = "button";
|
||||||
|
object.title = "hidden interaction";
|
||||||
|
object.textContent = state.visits % 2 ? "*" : "~";
|
||||||
|
document.body.appendChild(object);
|
||||||
|
let clicks = 0;
|
||||||
|
object.addEventListener("click", () => {
|
||||||
|
clicks += 1;
|
||||||
|
awardLayer(clicks > 3 ? 3 : 1, "corner object");
|
||||||
|
toast("Hidden interaction recorded.");
|
||||||
|
if (clicks === 7) window.location.href = "/play/left-behind.html";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogoSearchAndKeyboardSecrets(memory) {
|
||||||
|
// Events:
|
||||||
|
// - Click `.site-brand` repeatedly on the homepage.
|
||||||
|
// - Type exact words into `#search-input`.
|
||||||
|
// - Type phrases anywhere on the page.
|
||||||
|
const nav = document.querySelector(".site-brand");
|
||||||
|
if (nav) {
|
||||||
|
let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0);
|
||||||
|
nav.addEventListener("click", (event) => {
|
||||||
|
if (path === "/" || path === "/index.html") {
|
||||||
|
event.preventDefault();
|
||||||
|
taps += 1;
|
||||||
|
sessionStorage.setItem("zxh_logo_taps", String(taps));
|
||||||
|
awardLayer(taps >= 5 ? 3 : 1, "logo taps");
|
||||||
|
toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded.");
|
||||||
|
if (taps >= 8) window.location.href = "/play/dream-corridor.html";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = document.querySelector("#search-input");
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
const value = search.value.trim().toLowerCase();
|
||||||
|
if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]);
|
||||||
|
if (SEARCH_ROUTES[value]) {
|
||||||
|
awardLayer(layerForSearch(value), `search ${value}`);
|
||||||
|
redirectTo(SEARCH_ROUTES[value]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18);
|
||||||
|
sessionStorage.setItem("zxh_key_chain", chain);
|
||||||
|
KEYBOARD_SECRETS
|
||||||
|
.filter((secret) => chain.endsWith(secret.phrase))
|
||||||
|
.forEach(runSecretAction);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memory.seen?.length >= 6 && !state.travellerNoteShown) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast("Hidden path recorded.");
|
||||||
|
writeState({ ...readState(), travellerNoteShown: true });
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRareEvents() {
|
||||||
|
if (hour >= 22 || hour < 5) {
|
||||||
|
document.body.classList.add("hidden-late-night");
|
||||||
|
if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900);
|
||||||
|
}
|
||||||
|
const roll = Math.random();
|
||||||
|
if (roll < 0.003) {
|
||||||
|
window.setTimeout(() => showDriftNote("", ""), 1800);
|
||||||
|
awardLayer(3, "rare sensei appearance");
|
||||||
|
} else if (roll < 0.008) {
|
||||||
|
window.setTimeout(() => toast("Hidden warning."), 2400);
|
||||||
|
awardLayer(4, "future warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDriftNote(text, art) {
|
||||||
|
const panel = document.createElement("aside");
|
||||||
|
panel.className = "hidden-drift-note";
|
||||||
|
panel.innerHTML = `<div class="hidden-drift-message">${hiddenMessageMarkup(text)}</div><pre>${escapeHtml(art)}</pre><button type="button">Close</button>`;
|
||||||
|
panel.querySelector("button").addEventListener("click", () => panel.remove());
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSecretLinks() {
|
||||||
|
[
|
||||||
|
["/play/left-behind.html", "left behind"],
|
||||||
|
["/play/dream-corridor.html", "dream corridor"],
|
||||||
|
["/play/kitchen-light.html", "kitchen light"],
|
||||||
|
...lore.roomLinks
|
||||||
|
].forEach(([href, label], index) => {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "hidden-secret-link";
|
||||||
|
link.href = href;
|
||||||
|
link.textContent = label;
|
||||||
|
link.style.left = `${index + 1}px`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceErrors() {
|
||||||
|
if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) {
|
||||||
|
toast("Page not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContinuity(memory) {
|
||||||
|
const milestones = {
|
||||||
|
64: "Hidden layer unlocked."
|
||||||
|
};
|
||||||
|
if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) {
|
||||||
|
const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2;
|
||||||
|
awardLayer(layer, `visit milestone ${memory.visits}`);
|
||||||
|
window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200);
|
||||||
|
writeState({ ...readState(), [`milestone_${memory.visits}`]: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = memory.pathCounts?.[path] || 0;
|
||||||
|
if ([3, 7, 12].includes(count)) {
|
||||||
|
window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSeasonAndDates(memory) {
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn";
|
||||||
|
if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600);
|
||||||
|
const first = memory.firstSeen ? new Date(memory.firstSeen) : null;
|
||||||
|
if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) {
|
||||||
|
window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObjectConstellation() {
|
||||||
|
// Event: click the small keepsake buttons along the bottom rail.
|
||||||
|
const rail = document.createElement("div");
|
||||||
|
rail.className = "hidden-object-rail";
|
||||||
|
rail.setAttribute("aria-label", "Hidden objects");
|
||||||
|
const objects = [
|
||||||
|
["ring", "Hidden object recorded."],
|
||||||
|
["bot", "Hidden object recorded."],
|
||||||
|
["tea", "Hidden object recorded."],
|
||||||
|
["crayon", "Hidden object recorded."],
|
||||||
|
["clock", "Hidden object recorded."]
|
||||||
|
];
|
||||||
|
objects.forEach(([name, message]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = `hidden-keepsake hidden-keepsake--${name}`;
|
||||||
|
button.title = name;
|
||||||
|
button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name];
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const next = readState();
|
||||||
|
next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name]));
|
||||||
|
writeState(next);
|
||||||
|
awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`);
|
||||||
|
toast(message);
|
||||||
|
if (next.keepsakes.length >= objects.length) {
|
||||||
|
window.setTimeout(() => toast("Hidden object set completed."), 1100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rail.appendChild(button);
|
||||||
|
});
|
||||||
|
document.body.appendChild(rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeatherWindow(memory) {
|
||||||
|
// Event: click the small "window" button. This asks for browser geolocation.
|
||||||
|
if (memory.visits < 3 || !("geolocation" in navigator)) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "hidden-weather-window";
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Check outside weather";
|
||||||
|
button.textContent = "window";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
toast("Checking outside weather.");
|
||||||
|
navigator.geolocation.getCurrentPosition((pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords;
|
||||||
|
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}¤t=temperature_2m,precipitation&timezone=auto`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const current = data.current || {};
|
||||||
|
const rain = Number(current.precipitation || 0) > 0;
|
||||||
|
const temp = Math.round(Number(current.temperature_2m));
|
||||||
|
toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`);
|
||||||
|
})
|
||||||
|
.catch(() => toast("Weather check failed."));
|
||||||
|
}, () => toast("No outside weather today."));
|
||||||
|
});
|
||||||
|
document.body.appendChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOldWebLayer(memory) {
|
||||||
|
if (Math.random() < 0.08 || memory.visits > 10) {
|
||||||
|
const stamp = document.createElement("div");
|
||||||
|
stamp.className = "hidden-oldweb-stamp";
|
||||||
|
if (!lore.fakeUsers.length) return;
|
||||||
|
stamp.title = pick(lore.fakeUsers, "fake-user");
|
||||||
|
stamp.textContent = "best viewed with patience";
|
||||||
|
stamp.addEventListener("click", () => {
|
||||||
|
awardLayer(1, "old web stamp");
|
||||||
|
toast(stamp.title);
|
||||||
|
});
|
||||||
|
document.body.appendChild(stamp);
|
||||||
|
}
|
||||||
|
const comments = document.querySelector("#comments");
|
||||||
|
if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) {
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = "hidden-guestbook-line";
|
||||||
|
setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore"));
|
||||||
|
comments.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSourceRelics() {
|
||||||
|
const marker = document.createComment("hidden-details");
|
||||||
|
document.documentElement.appendChild(marker);
|
||||||
|
document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => {
|
||||||
|
if (index > 2) return;
|
||||||
|
img.alt = "Decorative image.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLongKeyboardSecrets() {
|
||||||
|
// Event: type longer hidden phrases anywhere on the page.
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24);
|
||||||
|
sessionStorage.setItem("zxh_second_chain", chain);
|
||||||
|
LONG_KEYBOARD_SECRETS
|
||||||
|
.filter((secret) => chain.endsWith(secret.phrase))
|
||||||
|
.forEach(runSecretAction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPatienceRewards(memory) {
|
||||||
|
// Event: no movement, key presses, scrolling, or clicking for 90 seconds.
|
||||||
|
if (memory.visits < 2) return;
|
||||||
|
let idleTimer = null;
|
||||||
|
const startIdle = () => {
|
||||||
|
window.clearTimeout(idleTimer);
|
||||||
|
idleTimer = window.setTimeout(() => {
|
||||||
|
awardLayer(5, "patience");
|
||||||
|
toast("Idle moment recorded.", 7600);
|
||||||
|
const next = readState();
|
||||||
|
next.patientMoments = (next.patientMoments || 0) + 1;
|
||||||
|
writeState(next);
|
||||||
|
}, 90000);
|
||||||
|
};
|
||||||
|
["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true }));
|
||||||
|
startIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRareSecondLayer() {
|
||||||
|
const roll = Math.random();
|
||||||
|
if ((path === "/" || path === "/index.html") && roll < 0.006) {
|
||||||
|
document.body.classList.add("hidden-home-takeover");
|
||||||
|
if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600);
|
||||||
|
} else if (roll < 0.002) {
|
||||||
|
window.setTimeout(() => showDriftNote("", ""), 1500);
|
||||||
|
} else if (roll < 0.006) {
|
||||||
|
if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000);
|
||||||
|
} else if (roll < 0.014) {
|
||||||
|
if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFamilyLayerIndex(memory) {
|
||||||
|
if (!path.includes("/play/family-layer-index")) return;
|
||||||
|
const target = document.querySelector("[data-family-layer-index]");
|
||||||
|
if (!target) return;
|
||||||
|
const visits = readState().layerVisits || {};
|
||||||
|
target.innerHTML = familyLayers.map((line, layer) => {
|
||||||
|
const count = visits[layer] || 0;
|
||||||
|
return `<li class="hidden-layer-message"><strong>${hiddenMessageMarkup(line)}</strong><br><span>local encounters: ${count}</span></li>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPageSpecificSecrets() {
|
||||||
|
// Events only used by specific `/play/...` pages.
|
||||||
|
if (path.includes("/play/cassette-log")) {
|
||||||
|
document.querySelectorAll("[data-cassette]").forEach((button, index) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
awardLayer(index >= 2 ? 4 : 2, "voice note");
|
||||||
|
if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600);
|
||||||
|
playTinySong();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.includes("/play/patience-game")) {
|
||||||
|
const target = document.querySelector("[data-patience-target]");
|
||||||
|
const count = document.querySelector("[data-patience-count]");
|
||||||
|
if (target && count) {
|
||||||
|
let seconds = 0;
|
||||||
|
setInterval(() => {
|
||||||
|
seconds += 1;
|
||||||
|
count.textContent = String(seconds);
|
||||||
|
if ([30, 90, 180].includes(seconds)) {
|
||||||
|
setHiddenMessage(target, "Patience milestone recorded.");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path.includes("/play/terminal-cupboard")) {
|
||||||
|
const input = document.querySelector("[data-cupboard-input]");
|
||||||
|
const log = document.querySelector("[data-cupboard-log]");
|
||||||
|
const commands = {
|
||||||
|
help: "commands: layer, exit",
|
||||||
|
layer: familyLayers.join("\n"),
|
||||||
|
exit: "Closed."
|
||||||
|
};
|
||||||
|
input?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const value = input.value.trim().toLowerCase();
|
||||||
|
const line = document.createElement("p");
|
||||||
|
setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`);
|
||||||
|
log.appendChild(line);
|
||||||
|
input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInvisibleHoverSecrets() {
|
||||||
|
document.querySelectorAll("h1, h2").forEach((heading, index) => {
|
||||||
|
if (index > 5) return;
|
||||||
|
heading.classList.add("hidden-hover-memory");
|
||||||
|
const source = [...lore.quotes, ...lore.journals, ...poems];
|
||||||
|
if (!source.length) return;
|
||||||
|
heading.dataset.hiddenMemory = pick(source, `heading-${index}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTinySong() {
|
||||||
|
try {
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContext) return;
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const notes = [392, 494, 440, 330, 392];
|
||||||
|
notes.forEach((freq, index) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.type = "sine";
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(ctx.currentTime + index * 0.18);
|
||||||
|
osc.stop(ctx.currentTime + index * 0.18 + 0.18);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features run in this order on every page after the DOM is ready.
|
||||||
|
// Each function receives the current `memory` object.
|
||||||
|
const FEATURES = [
|
||||||
|
addFooterNote,
|
||||||
|
addHomepageGreeting,
|
||||||
|
addWhispers,
|
||||||
|
addCornerObject,
|
||||||
|
addLogoSearchAndKeyboardSecrets,
|
||||||
|
addRareEvents,
|
||||||
|
addSecretLinks,
|
||||||
|
enhanceErrors,
|
||||||
|
addContinuity,
|
||||||
|
addSeasonAndDates,
|
||||||
|
addObjectConstellation,
|
||||||
|
addWeatherWindow,
|
||||||
|
addOldWebLayer,
|
||||||
|
addSourceRelics,
|
||||||
|
addLongKeyboardSecrets,
|
||||||
|
addPatienceRewards,
|
||||||
|
addRareSecondLayer,
|
||||||
|
addFamilyLayerIndex,
|
||||||
|
addPageSpecificSecrets,
|
||||||
|
addInvisibleHoverSecrets
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const memory = rememberVisit();
|
||||||
|
FEATURES.forEach((addFeature) => addFeature(memory));
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,315 +1,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
(function () {
|
||||||
if (typeof window.BiggerPicture !== 'function') {
|
"use strict";
|
||||||
console.error('[gallery-init] BiggerPicture not found. Check script path.');
|
var script = document.createElement("script");
|
||||||
return;
|
script.src = "/assets/scripts/ui/gallery-init.js";
|
||||||
}
|
script.defer = true;
|
||||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
|
document.head.appendChild(script);
|
||||||
|
}());
|
||||||
const isVideo = (href) => {
|
|
||||||
if (!href) return false;
|
|
||||||
href = href.toLowerCase();
|
|
||||||
return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1b) Convert MP4 links into inline <video> players
|
|
||||||
// Convert video links into inline <video> players BUT keep the <a>
|
|
||||||
const videoLinks = document.querySelectorAll('a[href]');
|
|
||||||
videoLinks.forEach(a => {
|
|
||||||
const href = a.getAttribute("href");
|
|
||||||
if (!isVideo(href)) return;
|
|
||||||
|
|
||||||
const video = document.createElement("video");
|
|
||||||
video.controls = true;
|
|
||||||
video.preload = "metadata";
|
|
||||||
video.style.maxWidth = "100%";
|
|
||||||
video.style.borderRadius = "6px";
|
|
||||||
|
|
||||||
const source = document.createElement("source");
|
|
||||||
source.src = href;
|
|
||||||
source.type = "video/" + href.split('.').pop(); // guesses correct mime
|
|
||||||
video.appendChild(source);
|
|
||||||
|
|
||||||
// Replace the link with the inline video
|
|
||||||
a.parentNode.replaceChild(video, a);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1) Wrap Org-exported images so they’re clickable
|
|
||||||
const imgs = document.querySelectorAll('.figure img, img.org-svg');
|
|
||||||
imgs.forEach((img) => {
|
|
||||||
if (img.closest('a')) return; // already wrapped
|
|
||||||
const a = document.createElement('a');
|
|
||||||
const href = img.currentSrc || img.src;
|
|
||||||
a.href = href;
|
|
||||||
a.dataset.img = href; // lets BP pre-size/raster slides
|
|
||||||
a.dataset.alt = img.alt || '';
|
|
||||||
const setDims = () => {
|
|
||||||
a.dataset.width = img.naturalWidth || img.width || 1920;
|
|
||||||
a.dataset.height = img.naturalHeight || img.height || 1080;
|
|
||||||
};
|
|
||||||
if (img.complete) setDims(); else img.addEventListener('load', setDims);
|
|
||||||
img.style.cursor = 'zoom-in';
|
|
||||||
img.parentElement.insertBefore(a, img);
|
|
||||||
a.appendChild(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) One global BP instance
|
|
||||||
const bp = BiggerPicture({ target: document.body });
|
|
||||||
|
|
||||||
// SVG pan/zoom handle
|
|
||||||
let activePanZoom = null;
|
|
||||||
const destroyPanZoom = () => { try { activePanZoom?.destroy(); } catch(_){} activePanZoom = null; };
|
|
||||||
|
|
||||||
// Simple rotate state (for non-SVG images)
|
|
||||||
let activeContainer = null;
|
|
||||||
let currentRotation = 0;
|
|
||||||
let rotateControls = null;
|
|
||||||
|
|
||||||
// 3) Build galleries per content container
|
|
||||||
const containers = document.querySelectorAll('main, article, .content, body');
|
|
||||||
containers.forEach((container) => {
|
|
||||||
const links = Array.from(container.querySelectorAll(
|
|
||||||
'.figure a, a:has(img.org-svg), a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"], a[href$=".m4v"], a[href$=".ogv"]'
|
|
||||||
));
|
|
||||||
if (!links.length) return;
|
|
||||||
|
|
||||||
// Start the lightbox on click
|
|
||||||
links.forEach((link, index) => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.querySelectorAll(".theme-toggle").forEach(el => {
|
|
||||||
el.classList.add("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
bp.open({
|
|
||||||
// IMPORTANT: pass the anchor ELEMENTS, not custom objects
|
|
||||||
items: links,
|
|
||||||
el: link,
|
|
||||||
caption: (el) => el.querySelector('img')?.alt || el.title || '',
|
|
||||||
maxZoom: 40, // for raster images (PNG/JPG); SVG handled separately
|
|
||||||
|
|
||||||
// Fade-out polish + cleanup
|
|
||||||
onClose(containerEl) {
|
|
||||||
destroyPanZoom();
|
|
||||||
teardownRotation();
|
|
||||||
if (containerEl) containerEl.classList.add('bp-fadeout');
|
|
||||||
const themeToggle = document.querySelector(".theme-toggle");
|
|
||||||
if (themeToggle) {
|
|
||||||
themeToggle.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Called once after open and on every slide change
|
|
||||||
//onOpen(containerEl) { setupRotation(containerEl); enhanceSVG(containerEl); }
|
|
||||||
onOpen(containerEl) {
|
|
||||||
const linkEl = bp.currItem;
|
|
||||||
const href = linkEl?.href || "";
|
|
||||||
|
|
||||||
if (href.endsWith(".mp4")) {
|
|
||||||
// Turn off image rotation (not applicable for video)
|
|
||||||
teardownRotation();
|
|
||||||
|
|
||||||
const htmlLayer = containerEl.querySelector(".bp-html");
|
|
||||||
const imgLayer = containerEl.querySelector(".bp-img");
|
|
||||||
|
|
||||||
if (!htmlLayer) return;
|
|
||||||
|
|
||||||
// Hide the default image layer
|
|
||||||
if (imgLayer) imgLayer.style.display = "none";
|
|
||||||
|
|
||||||
// Insert video player
|
|
||||||
htmlLayer.innerHTML = `
|
|
||||||
<video controls autoplay style="max-width:95vw; max-height:95vh">
|
|
||||||
<source src="${href}" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return; // Do not run image/SVG enhancements
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image behaviour
|
|
||||||
setupRotation(containerEl);
|
|
||||||
enhanceSVG(containerEl);
|
|
||||||
} ,
|
|
||||||
onUpdate(containerEl){ setupRotation(containerEl); enhanceSVG(containerEl); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4) Simple rotate buttons for raster images
|
|
||||||
function ensureRotateControls() {
|
|
||||||
if (rotateControls) return rotateControls;
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'bp-rotate-controls';
|
|
||||||
Object.assign(wrapper.style, {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '1.5rem',
|
|
||||||
right: '1.5rem',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
zIndex: '9999',
|
|
||||||
pointerEvents: 'auto'
|
|
||||||
});
|
|
||||||
|
|
||||||
const mkBtn = (label, title) => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.title = title;
|
|
||||||
btn.setAttribute('aria-label', title);
|
|
||||||
Object.assign(btn.style, {
|
|
||||||
padding: '0.4rem 0.6rem',
|
|
||||||
borderRadius: '999px',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: 'rgba(30,30,30,0.8)',
|
|
||||||
color: '#fff'
|
|
||||||
});
|
|
||||||
return btn;
|
|
||||||
};
|
|
||||||
|
|
||||||
const left = mkBtn('⟲', 'Rotate image 90° left');
|
|
||||||
const right = mkBtn('⟳', 'Rotate image 90° right');
|
|
||||||
|
|
||||||
left.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation(); // don’t close the lightbox
|
|
||||||
if (!activeContainer) return;
|
|
||||||
currentRotation = (currentRotation - 90 + 360) % 360;
|
|
||||||
applyRotation();
|
|
||||||
});
|
|
||||||
|
|
||||||
right.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!activeContainer) return;
|
|
||||||
currentRotation = (currentRotation + 90) % 360;
|
|
||||||
applyRotation();
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.append(left, right);
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
rotateControls = wrapper;
|
|
||||||
return rotateControls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRotateWrapper() {
|
|
||||||
if (!activeContainer) return null;
|
|
||||||
|
|
||||||
const imgRoot = activeContainer.querySelector('.bp-img');
|
|
||||||
if (!imgRoot) return null;
|
|
||||||
|
|
||||||
let imgEl = imgRoot.querySelector('img');
|
|
||||||
if (!imgEl) return null;
|
|
||||||
|
|
||||||
const src = (imgEl.currentSrc || imgEl.src || '').toLowerCase();
|
|
||||||
// We only rotate raster images; SVGs are handled via svg-pan-zoom
|
|
||||||
if (src.endsWith('.svg')) return null;
|
|
||||||
|
|
||||||
let wrapper = imgRoot.querySelector('.bp-rotate-wrapper');
|
|
||||||
if (!wrapper) {
|
|
||||||
wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'bp-rotate-wrapper';
|
|
||||||
wrapper.style.display = 'inline-block';
|
|
||||||
wrapper.style.transformOrigin = 'center center';
|
|
||||||
|
|
||||||
imgRoot.appendChild(wrapper);
|
|
||||||
wrapper.appendChild(imgEl);
|
|
||||||
} else if (!wrapper.contains(imgEl)) {
|
|
||||||
// Slide changed and BiggerPicture replaced the <img>
|
|
||||||
wrapper.innerHTML = '';
|
|
||||||
wrapper.appendChild(imgEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRotation() {
|
|
||||||
const wrapper = ensureRotateWrapper();
|
|
||||||
if (!wrapper) return;
|
|
||||||
wrapper.style.transform = `rotate(${currentRotation}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupRotation(containerEl) {
|
|
||||||
activeContainer = containerEl;
|
|
||||||
currentRotation = 0;
|
|
||||||
const controls = ensureRotateControls();
|
|
||||||
controls.style.display = 'flex';
|
|
||||||
applyRotation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function teardownRotation() {
|
|
||||||
activeContainer = null;
|
|
||||||
currentRotation = 0;
|
|
||||||
if (rotateControls) {
|
|
||||||
rotateControls.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 4) If current slide is an SVG, swap to inline + enable svg-pan-zoom
|
|
||||||
async function enhanceSVG(containerEl) {
|
|
||||||
try {
|
|
||||||
destroyPanZoom();
|
|
||||||
|
|
||||||
const imgEl = containerEl.querySelector('.bp-img img');
|
|
||||||
if (!imgEl) return;
|
|
||||||
|
|
||||||
const src = imgEl.currentSrc || imgEl.src || '';
|
|
||||||
const isSVG = src.toLowerCase().endsWith('.svg');
|
|
||||||
const htmlLayer = containerEl.querySelector('.bp-html');
|
|
||||||
if (!isSVG || !htmlLayer) {
|
|
||||||
// ensure any previous holder is removed and bitmap is visible
|
|
||||||
const old = htmlLayer?.querySelector('.bp-svg-holder');
|
|
||||||
if (old) old.remove();
|
|
||||||
imgEl.style.visibility = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/clear holder
|
|
||||||
let holder = htmlLayer.querySelector('.bp-svg-holder');
|
|
||||||
if (!holder) {
|
|
||||||
holder = document.createElement('div');
|
|
||||||
holder.className = 'bp-svg-holder';
|
|
||||||
holder.style.maxWidth = '95vw';
|
|
||||||
holder.style.maxHeight = '95vh';
|
|
||||||
htmlLayer.appendChild(holder);
|
|
||||||
}
|
|
||||||
holder.innerHTML = '';
|
|
||||||
|
|
||||||
// Hide the bitmap so only the inline SVG shows
|
|
||||||
imgEl.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
// Inline the SVG
|
|
||||||
const res = await fetch(src, { cache: 'force-cache' });
|
|
||||||
const text = await res.text();
|
|
||||||
holder.innerHTML = text;
|
|
||||||
|
|
||||||
const svg = holder.querySelector('svg');
|
|
||||||
if (!svg) { imgEl.style.visibility = ''; return; }
|
|
||||||
|
|
||||||
svg.style.maxWidth = '95vw';
|
|
||||||
svg.style.maxHeight = '95vh';
|
|
||||||
svg.style.display = 'block';
|
|
||||||
|
|
||||||
if (typeof window.svgPanZoom === 'function') {
|
|
||||||
activePanZoom = svgPanZoom(svg, {
|
|
||||||
zoomEnabled: true,
|
|
||||||
controlIconsEnabled: true,
|
|
||||||
fit: true,
|
|
||||||
center: true,
|
|
||||||
minZoom: 0.05,
|
|
||||||
maxZoom: 400, // effectively "unlimited"
|
|
||||||
zoomScaleSensitivity: 0.25,
|
|
||||||
dblClickZoomEnabled: true
|
|
||||||
});
|
|
||||||
// Keep wheel inside lightbox
|
|
||||||
holder.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
|
|
||||||
} else {
|
|
||||||
console.warn('[gallery-init] svg-pan-zoom not loaded');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[gallery-init] SVG enhance failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,691 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var script = document.createElement("script");
|
||||||
/*
|
script.src = "/assets/scripts/features/hidden-details.js";
|
||||||
* Hidden site details
|
script.defer = true;
|
||||||
* -------------------
|
document.head.appendChild(script);
|
||||||
* This file adds small hidden interactions across the site: footer notes,
|
|
||||||
* click targets, search/keyboard secrets, rare messages, and page-specific
|
|
||||||
* behavior for the hidden /play pages.
|
|
||||||
*
|
|
||||||
* How to add your own things:
|
|
||||||
* - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`,
|
|
||||||
* `greetings`, or `lore`.
|
|
||||||
* - Add search-triggered toast messages to `SEARCH_TOASTS`.
|
|
||||||
* - Add search-triggered page redirects to `SEARCH_ROUTES`.
|
|
||||||
* - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`.
|
|
||||||
* - Add a new feature by writing an `addYourFeature(memory)` function and
|
|
||||||
* adding it to the `FEATURES` list at the bottom of the file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Storage keys. v1 is read once so old visitors keep their hidden progress.
|
|
||||||
const STORAGE_KEY = "zxh_hidden_details_v2";
|
|
||||||
const OLD_STORAGE_KEY = "zxh_hidden_details_v1";
|
|
||||||
|
|
||||||
// Shared page context. These are captured once so daily random picks stay stable.
|
|
||||||
const now = new Date();
|
|
||||||
const hour = now.getHours();
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const state = readState();
|
|
||||||
const CHARACTER_AVATARS = {
|
|
||||||
"lima": {
|
|
||||||
avatar: "/assets/avatars/lima.jpg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["lima"],
|
|
||||||
glow: "#f2c58b"
|
|
||||||
},
|
|
||||||
"aphy": {
|
|
||||||
avatar: "/assets/avatars/aphy.jpeg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["aphy"],
|
|
||||||
glow: "#9dd3a6"
|
|
||||||
},
|
|
||||||
"sensei chi": {
|
|
||||||
avatar: "/assets/avatars/sensei-chi.jpeg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["sensei chi", "sensei"],
|
|
||||||
glow: "#9ccddd"
|
|
||||||
},
|
|
||||||
"young z": {
|
|
||||||
avatar: "/assets/avatars/young-z.jpeg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["young z", "young"],
|
|
||||||
glow: "#f0b85c"
|
|
||||||
},
|
|
||||||
"future z": {
|
|
||||||
avatar: "/assets/avatars/future-z.jpeg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["future z", "future"],
|
|
||||||
glow: "#c2a4ee"
|
|
||||||
},
|
|
||||||
"z": {
|
|
||||||
avatar: "/assets/avatars/z.jpeg",
|
|
||||||
fallback: "/assets/avatars/z.jpeg",
|
|
||||||
aliases: ["z", "zaine"],
|
|
||||||
glow: "#ead68e"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// EDITABLE CONTENT
|
|
||||||
// -----------------------------
|
|
||||||
// Generated by the hidden narrative authoring page.
|
|
||||||
// Friendly source of truth: assets/content/hidden-details.json
|
|
||||||
|
|
||||||
const familyLayers = [];
|
|
||||||
|
|
||||||
const details = [];
|
|
||||||
|
|
||||||
const poems = [];
|
|
||||||
|
|
||||||
const greetings = [];
|
|
||||||
|
|
||||||
const nightMessages = [];
|
|
||||||
|
|
||||||
const lore = {
|
|
||||||
"quotes": [],
|
|
||||||
"conversations": [],
|
|
||||||
"journals": [],
|
|
||||||
"warnings": [],
|
|
||||||
"dreams": [],
|
|
||||||
"cassettes": [],
|
|
||||||
"fakeUsers": [],
|
|
||||||
"seasonal": {},
|
|
||||||
"homepageTakeovers": [],
|
|
||||||
"roomLinks": []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exact search text -> toast message.
|
|
||||||
const SEARCH_TOASTS = {};
|
|
||||||
|
|
||||||
// Exact search text -> hidden page route.
|
|
||||||
const SEARCH_ROUTES = {};
|
|
||||||
|
|
||||||
// Short typed phrases. Stored in sessionStorage as a rolling key chain.
|
|
||||||
const KEYBOARD_SECRETS = [];
|
|
||||||
|
|
||||||
// Longer typed phrases and character names.
|
|
||||||
const LONG_KEYBOARD_SECRETS = [];
|
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// STATE HELPERS
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
function readState() {
|
|
||||||
let current = {};
|
|
||||||
try {
|
|
||||||
current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
|
||||||
} catch (_) {}
|
|
||||||
if (!current.visits) {
|
|
||||||
try {
|
|
||||||
const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}");
|
|
||||||
current = { ...oldState, migratedFromV1: true };
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeState(next) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(list, salt) {
|
|
||||||
const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`);
|
|
||||||
return list[seed % list.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hash(value) {
|
|
||||||
let h = 2166136261;
|
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
|
||||||
h ^= value.charCodeAt(i);
|
|
||||||
h = Math.imul(h, 16777619);
|
|
||||||
}
|
|
||||||
return Math.abs(h >>> 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// UI HELPERS
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
function characterForMessage(message) {
|
|
||||||
const text = String(message || "").toLowerCase();
|
|
||||||
return Object.entries(CHARACTER_AVATARS).find(([, config]) => {
|
|
||||||
return (config.aliases || []).some((alias) => {
|
|
||||||
const escaped = alias.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
return new RegExp(`(^|[^a-z0-9-])${escaped}([^a-z0-9-]|$)`).test(text);
|
|
||||||
});
|
|
||||||
})?.[0] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function avatarMarkup(character) {
|
|
||||||
const config = CHARACTER_AVATARS[character];
|
|
||||||
if (!config) return "";
|
|
||||||
return `<img class="hidden-message-avatar" src="${escapeHtml(config.avatar)}" alt="" loading="lazy" style="--hidden-avatar-glow:${escapeHtml(config.glow)}" onerror="this.onerror=null;this.src='${escapeHtml(config.fallback)}'">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hiddenMessageMarkup(message) {
|
|
||||||
const character = characterForMessage(message);
|
|
||||||
return character
|
|
||||||
? `${avatarMarkup(character)}<span class="hidden-message-text">${escapeHtml(message)}</span>`
|
|
||||||
: `<span class="hidden-message-text">${escapeHtml(message)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setHiddenMessage(el, message) {
|
|
||||||
const character = characterForMessage(message);
|
|
||||||
el.classList.toggle("has-hidden-avatar", Boolean(character));
|
|
||||||
if (character) {
|
|
||||||
el.dataset.hiddenCharacter = character;
|
|
||||||
el.innerHTML = hiddenMessageMarkup(message);
|
|
||||||
} else {
|
|
||||||
delete el.dataset.hiddenCharacter;
|
|
||||||
el.textContent = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toast(message, duration) {
|
|
||||||
let el = document.querySelector(".hidden-toast");
|
|
||||||
if (!el) {
|
|
||||||
el = document.createElement("div");
|
|
||||||
el.className = "hidden-toast";
|
|
||||||
el.setAttribute("role", "status");
|
|
||||||
document.body.appendChild(el);
|
|
||||||
}
|
|
||||||
setHiddenMessage(el, message);
|
|
||||||
el.classList.add("is-visible");
|
|
||||||
window.clearTimeout(el._timer);
|
|
||||||
el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectTo(route) {
|
|
||||||
window.location.href = route;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runSecretAction(secret) {
|
|
||||||
if (secret.route) redirectTo(secret.route);
|
|
||||||
if (secret.message) toast(secret.message);
|
|
||||||
if (secret.song) playTinySong();
|
|
||||||
}
|
|
||||||
|
|
||||||
function layerForSearch(value) {
|
|
||||||
if (value.includes("future")) return 4;
|
|
||||||
if (value.includes("sensei") || value.includes("aphy")) return 3;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberVisit() {
|
|
||||||
const visits = (state.visits || 0) + 1;
|
|
||||||
const seen = Array.isArray(state.seen) ? state.seen : [];
|
|
||||||
const pathCounts = { ...(state.pathCounts || {}) };
|
|
||||||
pathCounts[path] = (pathCounts[path] || 0) + 1;
|
|
||||||
const layerVisits = { ...(state.layerVisits || {}) };
|
|
||||||
const next = {
|
|
||||||
...state,
|
|
||||||
visits,
|
|
||||||
lastPath: path,
|
|
||||||
pathCounts,
|
|
||||||
layerVisits,
|
|
||||||
seen: Array.from(new Set([...seen, path])).slice(-100),
|
|
||||||
firstSeen: state.firstSeen || new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString()
|
|
||||||
};
|
|
||||||
writeState(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function awardLayer(layer, reason) {
|
|
||||||
const next = readState();
|
|
||||||
next.layerVisits = { ...(next.layerVisits || {}) };
|
|
||||||
next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1;
|
|
||||||
next.lastLayerReason = reason;
|
|
||||||
writeState(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// GLOBAL FEATURES
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
function addFooterNote(memory) {
|
|
||||||
const footer = document.querySelector("footer");
|
|
||||||
if (!footer || !details.length) return;
|
|
||||||
const note = document.createElement("p");
|
|
||||||
note.className = "hidden-footer-note";
|
|
||||||
const base = pick(details, "footer");
|
|
||||||
setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base);
|
|
||||||
footer.appendChild(note);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHomepageGreeting(memory) {
|
|
||||||
const target = document.querySelector("#db-greeting");
|
|
||||||
if (!target || !greetings.length) return;
|
|
||||||
setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`));
|
|
||||||
const extra = document.createElement("p");
|
|
||||||
extra.className = "hidden-greeting";
|
|
||||||
setHiddenMessage(extra, "");
|
|
||||||
target.closest("div")?.appendChild(extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWhispers() {
|
|
||||||
// Event: click one of the tiny "?" marks appended to long paragraphs.
|
|
||||||
const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80);
|
|
||||||
paragraphs.slice(0, 5).forEach((p, index) => {
|
|
||||||
if ((hash(path + index) + index) % 3 !== 0) return;
|
|
||||||
const mark = document.createElement("span");
|
|
||||||
mark.className = "hidden-whisper";
|
|
||||||
mark.tabIndex = 0;
|
|
||||||
mark.textContent = " ?";
|
|
||||||
const source = index % 2 ? poems : details;
|
|
||||||
if (!source.length) return;
|
|
||||||
mark.title = pick(source, `whisper-${index}`);
|
|
||||||
mark.addEventListener("click", () => {
|
|
||||||
awardLayer(index % 2 ? 2 : 1, "paragraph whisper");
|
|
||||||
toast(mark.title);
|
|
||||||
});
|
|
||||||
p.appendChild(mark);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCornerObject() {
|
|
||||||
// Event: click the small fixed button in the bottom-left corner.
|
|
||||||
const object = document.createElement("button");
|
|
||||||
object.className = "hidden-corner-object";
|
|
||||||
object.type = "button";
|
|
||||||
object.title = "hidden interaction";
|
|
||||||
object.textContent = state.visits % 2 ? "*" : "~";
|
|
||||||
document.body.appendChild(object);
|
|
||||||
let clicks = 0;
|
|
||||||
object.addEventListener("click", () => {
|
|
||||||
clicks += 1;
|
|
||||||
awardLayer(clicks > 3 ? 3 : 1, "corner object");
|
|
||||||
toast("Hidden interaction recorded.");
|
|
||||||
if (clicks === 7) window.location.href = "/play/left-behind.html";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLogoSearchAndKeyboardSecrets(memory) {
|
|
||||||
// Events:
|
|
||||||
// - Click `.site-brand` repeatedly on the homepage.
|
|
||||||
// - Type exact words into `#search-input`.
|
|
||||||
// - Type phrases anywhere on the page.
|
|
||||||
const nav = document.querySelector(".site-brand");
|
|
||||||
if (nav) {
|
|
||||||
let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0);
|
|
||||||
nav.addEventListener("click", (event) => {
|
|
||||||
if (path === "/" || path === "/index.html") {
|
|
||||||
event.preventDefault();
|
|
||||||
taps += 1;
|
|
||||||
sessionStorage.setItem("zxh_logo_taps", String(taps));
|
|
||||||
awardLayer(taps >= 5 ? 3 : 1, "logo taps");
|
|
||||||
toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded.");
|
|
||||||
if (taps >= 8) window.location.href = "/play/dream-corridor.html";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = document.querySelector("#search-input");
|
|
||||||
if (search) {
|
|
||||||
search.addEventListener("input", () => {
|
|
||||||
const value = search.value.trim().toLowerCase();
|
|
||||||
if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]);
|
|
||||||
if (SEARCH_ROUTES[value]) {
|
|
||||||
awardLayer(layerForSearch(value), `search ${value}`);
|
|
||||||
redirectTo(SEARCH_ROUTES[value]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18);
|
|
||||||
sessionStorage.setItem("zxh_key_chain", chain);
|
|
||||||
KEYBOARD_SECRETS
|
|
||||||
.filter((secret) => chain.endsWith(secret.phrase))
|
|
||||||
.forEach(runSecretAction);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (memory.seen?.length >= 6 && !state.travellerNoteShown) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
toast("Hidden path recorded.");
|
|
||||||
writeState({ ...readState(), travellerNoteShown: true });
|
|
||||||
}, 1600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRareEvents() {
|
|
||||||
if (hour >= 22 || hour < 5) {
|
|
||||||
document.body.classList.add("hidden-late-night");
|
|
||||||
if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900);
|
|
||||||
}
|
|
||||||
const roll = Math.random();
|
|
||||||
if (roll < 0.003) {
|
|
||||||
window.setTimeout(() => showDriftNote("", ""), 1800);
|
|
||||||
awardLayer(3, "rare sensei appearance");
|
|
||||||
} else if (roll < 0.008) {
|
|
||||||
window.setTimeout(() => toast("Hidden warning."), 2400);
|
|
||||||
awardLayer(4, "future warning");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDriftNote(text, art) {
|
|
||||||
const panel = document.createElement("aside");
|
|
||||||
panel.className = "hidden-drift-note";
|
|
||||||
const character = characterForMessage(text);
|
|
||||||
panel.innerHTML = `<div class="hidden-drift-message${character ? " has-hidden-avatar" : ""}">${hiddenMessageMarkup(text)}</div><pre>${escapeHtml(art)}</pre><button type="button">Close</button>`;
|
|
||||||
panel.querySelector("button").addEventListener("click", () => panel.remove());
|
|
||||||
document.body.appendChild(panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSecretLinks() {
|
|
||||||
[
|
|
||||||
["/play/left-behind.html", "left behind"],
|
|
||||||
["/play/dream-corridor.html", "dream corridor"],
|
|
||||||
["/play/kitchen-light.html", "kitchen light"],
|
|
||||||
...lore.roomLinks
|
|
||||||
].forEach(([href, label], index) => {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.className = "hidden-secret-link";
|
|
||||||
link.href = href;
|
|
||||||
link.textContent = label;
|
|
||||||
link.style.left = `${index + 1}px`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function enhanceErrors() {
|
|
||||||
if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) {
|
|
||||||
toast("Page not found.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addContinuity(memory) {
|
|
||||||
const milestones = {
|
|
||||||
64: "Hidden layer unlocked."
|
|
||||||
};
|
|
||||||
if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) {
|
|
||||||
const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2;
|
|
||||||
awardLayer(layer, `visit milestone ${memory.visits}`);
|
|
||||||
window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200);
|
|
||||||
writeState({ ...readState(), [`milestone_${memory.visits}`]: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = memory.pathCounts?.[path] || 0;
|
|
||||||
if ([3, 7, 12].includes(count)) {
|
|
||||||
window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSeasonAndDates(memory) {
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn";
|
|
||||||
if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600);
|
|
||||||
const first = memory.firstSeen ? new Date(memory.firstSeen) : null;
|
|
||||||
if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) {
|
|
||||||
window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addObjectConstellation() {
|
|
||||||
// Event: click the small keepsake buttons along the bottom rail.
|
|
||||||
const rail = document.createElement("div");
|
|
||||||
rail.className = "hidden-object-rail";
|
|
||||||
rail.setAttribute("aria-label", "Hidden objects");
|
|
||||||
const objects = [
|
|
||||||
["ring", "Hidden object recorded."],
|
|
||||||
["bot", "Hidden object recorded."],
|
|
||||||
["tea", "Hidden object recorded."],
|
|
||||||
["crayon", "Hidden object recorded."],
|
|
||||||
["clock", "Hidden object recorded."]
|
|
||||||
];
|
|
||||||
objects.forEach(([name, message]) => {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = `hidden-keepsake hidden-keepsake--${name}`;
|
|
||||||
button.title = name;
|
|
||||||
button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name];
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const next = readState();
|
|
||||||
next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name]));
|
|
||||||
writeState(next);
|
|
||||||
awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`);
|
|
||||||
toast(message);
|
|
||||||
if (next.keepsakes.length >= objects.length) {
|
|
||||||
window.setTimeout(() => toast("Hidden object set completed."), 1100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
rail.appendChild(button);
|
|
||||||
});
|
|
||||||
document.body.appendChild(rail);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWeatherWindow(memory) {
|
|
||||||
// Event: click the small "window" button. This asks for browser geolocation.
|
|
||||||
if (memory.visits < 3 || !("geolocation" in navigator)) return;
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.className = "hidden-weather-window";
|
|
||||||
button.type = "button";
|
|
||||||
button.title = "Check outside weather";
|
|
||||||
button.textContent = "window";
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
toast("Checking outside weather.");
|
|
||||||
navigator.geolocation.getCurrentPosition((pos) => {
|
|
||||||
const { latitude, longitude } = pos.coords;
|
|
||||||
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}¤t=temperature_2m,precipitation&timezone=auto`)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const current = data.current || {};
|
|
||||||
const rain = Number(current.precipitation || 0) > 0;
|
|
||||||
const temp = Math.round(Number(current.temperature_2m));
|
|
||||||
toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`);
|
|
||||||
})
|
|
||||||
.catch(() => toast("Weather check failed."));
|
|
||||||
}, () => toast("No outside weather today."));
|
|
||||||
});
|
|
||||||
document.body.appendChild(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addOldWebLayer(memory) {
|
|
||||||
if (Math.random() < 0.08 || memory.visits > 10) {
|
|
||||||
const stamp = document.createElement("div");
|
|
||||||
stamp.className = "hidden-oldweb-stamp";
|
|
||||||
if (!lore.fakeUsers.length) return;
|
|
||||||
stamp.title = pick(lore.fakeUsers, "fake-user");
|
|
||||||
stamp.textContent = "best viewed with patience";
|
|
||||||
stamp.addEventListener("click", () => {
|
|
||||||
awardLayer(1, "old web stamp");
|
|
||||||
toast(stamp.title);
|
|
||||||
});
|
|
||||||
document.body.appendChild(stamp);
|
|
||||||
}
|
|
||||||
const comments = document.querySelector("#comments");
|
|
||||||
if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) {
|
|
||||||
const line = document.createElement("p");
|
|
||||||
line.className = "hidden-guestbook-line";
|
|
||||||
setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore"));
|
|
||||||
comments.appendChild(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSourceRelics() {
|
|
||||||
const marker = document.createComment("hidden-details");
|
|
||||||
document.documentElement.appendChild(marker);
|
|
||||||
document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => {
|
|
||||||
if (index > 2) return;
|
|
||||||
img.alt = "Decorative image.";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLongKeyboardSecrets() {
|
|
||||||
// Event: type longer hidden phrases anywhere on the page.
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24);
|
|
||||||
sessionStorage.setItem("zxh_second_chain", chain);
|
|
||||||
LONG_KEYBOARD_SECRETS
|
|
||||||
.filter((secret) => chain.endsWith(secret.phrase))
|
|
||||||
.forEach(runSecretAction);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPatienceRewards(memory) {
|
|
||||||
// Event: no movement, key presses, scrolling, or clicking for 90 seconds.
|
|
||||||
if (memory.visits < 2) return;
|
|
||||||
let idleTimer = null;
|
|
||||||
const startIdle = () => {
|
|
||||||
window.clearTimeout(idleTimer);
|
|
||||||
idleTimer = window.setTimeout(() => {
|
|
||||||
awardLayer(5, "patience");
|
|
||||||
toast("Idle moment recorded.", 7600);
|
|
||||||
const next = readState();
|
|
||||||
next.patientMoments = (next.patientMoments || 0) + 1;
|
|
||||||
writeState(next);
|
|
||||||
}, 90000);
|
|
||||||
};
|
|
||||||
["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true }));
|
|
||||||
startIdle();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRareSecondLayer() {
|
|
||||||
const roll = Math.random();
|
|
||||||
if ((path === "/" || path === "/index.html") && roll < 0.006) {
|
|
||||||
document.body.classList.add("hidden-home-takeover");
|
|
||||||
if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600);
|
|
||||||
} else if (roll < 0.002) {
|
|
||||||
window.setTimeout(() => showDriftNote("", ""), 1500);
|
|
||||||
} else if (roll < 0.006) {
|
|
||||||
if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000);
|
|
||||||
} else if (roll < 0.014) {
|
|
||||||
if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFamilyLayerIndex(memory) {
|
|
||||||
if (!path.includes("/play/family-layer-index")) return;
|
|
||||||
const target = document.querySelector("[data-family-layer-index]");
|
|
||||||
if (!target) return;
|
|
||||||
const visits = readState().layerVisits || {};
|
|
||||||
target.innerHTML = familyLayers.map((line, layer) => {
|
|
||||||
const count = visits[layer] || 0;
|
|
||||||
const character = characterForMessage(line);
|
|
||||||
return `<li class="${character ? "hidden-layer-message" : ""}"><strong>${hiddenMessageMarkup(line)}</strong><br><span>local encounters: ${count}</span></li>`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPageSpecificSecrets() {
|
|
||||||
// Events only used by specific `/play/...` pages.
|
|
||||||
if (path.includes("/play/cassette-log")) {
|
|
||||||
document.querySelectorAll("[data-cassette]").forEach((button, index) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
awardLayer(index >= 2 ? 4 : 2, "voice note");
|
|
||||||
if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600);
|
|
||||||
playTinySong();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (path.includes("/play/patience-game")) {
|
|
||||||
const target = document.querySelector("[data-patience-target]");
|
|
||||||
const count = document.querySelector("[data-patience-count]");
|
|
||||||
if (target && count) {
|
|
||||||
let seconds = 0;
|
|
||||||
setInterval(() => {
|
|
||||||
seconds += 1;
|
|
||||||
count.textContent = String(seconds);
|
|
||||||
if ([30, 90, 180].includes(seconds)) {
|
|
||||||
setHiddenMessage(target, "Patience milestone recorded.");
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (path.includes("/play/terminal-cupboard")) {
|
|
||||||
const input = document.querySelector("[data-cupboard-input]");
|
|
||||||
const log = document.querySelector("[data-cupboard-log]");
|
|
||||||
const commands = {
|
|
||||||
help: "commands: layer, exit",
|
|
||||||
layer: familyLayers.join("\n"),
|
|
||||||
exit: "Closed."
|
|
||||||
};
|
|
||||||
input?.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter") return;
|
|
||||||
const value = input.value.trim().toLowerCase();
|
|
||||||
const line = document.createElement("p");
|
|
||||||
setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`);
|
|
||||||
log.appendChild(line);
|
|
||||||
input.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addInvisibleHoverSecrets() {
|
|
||||||
document.querySelectorAll("h1, h2").forEach((heading, index) => {
|
|
||||||
if (index > 5) return;
|
|
||||||
heading.classList.add("hidden-hover-memory");
|
|
||||||
const source = [...lore.quotes, ...lore.journals, ...poems];
|
|
||||||
if (!source.length) return;
|
|
||||||
heading.dataset.hiddenMemory = pick(source, `heading-${index}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playTinySong() {
|
|
||||||
try {
|
|
||||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (!AudioContext) return;
|
|
||||||
const ctx = new AudioContext();
|
|
||||||
const notes = [392, 494, 440, 330, 392];
|
|
||||||
notes.forEach((freq, index) => {
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
osc.frequency.value = freq;
|
|
||||||
osc.type = "sine";
|
|
||||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16);
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.start(ctx.currentTime + index * 0.18);
|
|
||||||
osc.stop(ctx.currentTime + index * 0.18 + 0.18);
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value).replace(/[&<>"']/g, (char) => ({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'"
|
|
||||||
}[char]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Features run in this order on every page after the DOM is ready.
|
|
||||||
// Each function receives the current `memory` object.
|
|
||||||
const FEATURES = [
|
|
||||||
addFooterNote,
|
|
||||||
addHomepageGreeting,
|
|
||||||
addWhispers,
|
|
||||||
addCornerObject,
|
|
||||||
addLogoSearchAndKeyboardSecrets,
|
|
||||||
addRareEvents,
|
|
||||||
addSecretLinks,
|
|
||||||
enhanceErrors,
|
|
||||||
addContinuity,
|
|
||||||
addSeasonAndDates,
|
|
||||||
addObjectConstellation,
|
|
||||||
addWeatherWindow,
|
|
||||||
addOldWebLayer,
|
|
||||||
addSourceRelics,
|
|
||||||
addLongKeyboardSecrets,
|
|
||||||
addPatienceRewards,
|
|
||||||
addRareSecondLayer,
|
|
||||||
addFamilyLayerIndex,
|
|
||||||
addPageSpecificSecrets,
|
|
||||||
addInvisibleHoverSecrets
|
|
||||||
];
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const memory = rememberVisit();
|
|
||||||
FEATURES.forEach((addFeature) => addFeature(memory));
|
|
||||||
});
|
|
||||||
}());
|
}());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2817
assets/scripts/mermaid.min.js
vendored
2817
assets/scripts/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,148 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const wall = document.getElementById("notes-wall");
|
"use strict";
|
||||||
const form = document.getElementById("notes-form");
|
var script = document.createElement("script");
|
||||||
const authorInput = document.getElementById("note-author");
|
script.src = "/assets/scripts/pages/notes.js";
|
||||||
const contentInput = document.getElementById("note-content");
|
script.defer = true;
|
||||||
const authorFilter = document.getElementById("notes-author-filter");
|
document.head.appendChild(script);
|
||||||
let notesCache = [];
|
}());
|
||||||
|
|
||||||
if (!wall) return;
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNote(note) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "note";
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="note-meta">
|
|
||||||
<span class="note-author">${escapeHtml(note.author_name)}</span>
|
|
||||||
<time datetime="${note.created_at}">
|
|
||||||
${new Date(note.created_at).toLocaleString()}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<div class="note-content">
|
|
||||||
${escapeHtml(note.content).replace(/\n/g, "<br>")}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normaliseAuthor(author) {
|
|
||||||
return (author || "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateAuthorFilter(notes) {
|
|
||||||
if (!authorFilter) return;
|
|
||||||
|
|
||||||
const previousValue = authorFilter.value;
|
|
||||||
const authors = Array.from(
|
|
||||||
new Set(notes.map(note => normaliseAuthor(note.author_name)).filter(Boolean))
|
|
||||||
).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
||||||
|
|
||||||
authorFilter.innerHTML = '<option value="">All authors</option>';
|
|
||||||
|
|
||||||
authors.forEach(author => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = author;
|
|
||||||
option.textContent = author;
|
|
||||||
authorFilter.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
authorFilter.value = authors.includes(previousValue) ? previousValue : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotes() {
|
|
||||||
wall.innerHTML = "";
|
|
||||||
|
|
||||||
const selectedAuthor = authorFilter ? authorFilter.value : "";
|
|
||||||
const notes = selectedAuthor
|
|
||||||
? notesCache.filter(note => normaliseAuthor(note.author_name) === selectedAuthor)
|
|
||||||
: notesCache;
|
|
||||||
|
|
||||||
if (notesCache.length === 0) {
|
|
||||||
wall.innerHTML = "<p>No notes yet.</p>";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notes.length === 0) {
|
|
||||||
wall.innerHTML = "<p>No notes for this author.</p>";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notes.forEach(note => {
|
|
||||||
wall.appendChild(renderNote(note));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadNotes() {
|
|
||||||
wall.innerHTML = "<p>Loading notes…</p>";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/notes");
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch notes");
|
|
||||||
|
|
||||||
const notes = await res.json();
|
|
||||||
notesCache = notes;
|
|
||||||
populateAuthorFilter(notesCache);
|
|
||||||
renderNotes();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
wall.innerHTML = "<p>Could not load notes.</p>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitNote(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const author = authorInput.value.trim();
|
|
||||||
const content = contentInput.value.trim();
|
|
||||||
|
|
||||||
if (!author || !content) return;
|
|
||||||
|
|
||||||
form.querySelector("button").disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/notes", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
author_name: author,
|
|
||||||
content: content,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new Error(err.detail || "Failed to post note");
|
|
||||||
}
|
|
||||||
|
|
||||||
contentInput.value = "";
|
|
||||||
await loadNotes();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
form.querySelector("button").disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener("submit", submitNote);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorFilter) {
|
|
||||||
authorFilter.addEventListener("change", renderNotes);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNotes();
|
|
||||||
})();
|
|
||||||
|
|||||||
1008
assets/scripts/pages/ash-below-lake.js
Normal file
1008
assets/scripts/pages/ash-below-lake.js
Normal file
File diff suppressed because it is too large
Load Diff
240
assets/scripts/pages/competency-status-board.js
Normal file
240
assets/scripts/pages/competency-status-board.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
const STATES = [
|
||||||
|
{ key: "completed", label: "Completed" },
|
||||||
|
{ key: "manager_review", label: "Manager Review" },
|
||||||
|
{ key: "in_progress", label: "In Progress" },
|
||||||
|
{ key: "not_started", label: "Not Started" },
|
||||||
|
{ key: "comments", label: "Comments" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVELS = [
|
||||||
|
{ key: "graduate", label: "Graduate" },
|
||||||
|
{ key: "engineer", label: "Engineer" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentLevel = LEVELS[0].key;
|
||||||
|
|
||||||
|
function isMobile() {
|
||||||
|
return window.matchMedia("(max-width: 600px)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLevelTabs() {
|
||||||
|
const board = document.getElementById("kanban-board");
|
||||||
|
if (!board || document.getElementById("level-tabs")) return;
|
||||||
|
|
||||||
|
const nav = document.createElement("div");
|
||||||
|
nav.id = "level-tabs";
|
||||||
|
|
||||||
|
LEVELS.forEach(({ key, label }) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.dataset.level = key;
|
||||||
|
btn.className = "level-tab" + (key === currentLevel ? " active" : "");
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
currentLevel = key;
|
||||||
|
document.querySelectorAll(".level-tab").forEach(b =>
|
||||||
|
b.classList.toggle("active", b.dataset.level === key)
|
||||||
|
);
|
||||||
|
loadBoard();
|
||||||
|
});
|
||||||
|
nav.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
board.insertAdjacentElement("beforebegin", nav);
|
||||||
|
|
||||||
|
if (!document.getElementById("level-tab-styles")) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "level-tab-styles";
|
||||||
|
style.textContent = `
|
||||||
|
#level-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.level-tab {
|
||||||
|
padding: 0.4em 1.2em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.level-tab.active {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateMobileControls(items) {
|
||||||
|
if (!isMobile()) return;
|
||||||
|
|
||||||
|
const panel = document.getElementById("mobile-move-panel");
|
||||||
|
const itemSelect = document.getElementById("move-item");
|
||||||
|
const fromSelect = document.getElementById("move-from");
|
||||||
|
const toSelect = document.getElementById("move-to");
|
||||||
|
|
||||||
|
itemSelect.innerHTML = "<option value=''>Select competency</option>";
|
||||||
|
fromSelect.innerHTML = "<option value=''>From</option>";
|
||||||
|
toSelect.innerHTML = "<option value=''>To</option>";
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = item.id;
|
||||||
|
opt.textContent = item.title;
|
||||||
|
opt.dataset.state = item.state;
|
||||||
|
itemSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
STATES.forEach((s) => {
|
||||||
|
fromSelect.appendChild(new Option(s.label, s.key));
|
||||||
|
toSelect.appendChild(new Option(s.label, s.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
itemSelect.onchange = () => {
|
||||||
|
const selected = itemSelect.selectedOptions[0];
|
||||||
|
if (selected?.dataset.state) {
|
||||||
|
fromSelect.value = selected.dataset.state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveConfirmBtn = document.getElementById("move-confirm");
|
||||||
|
if (moveConfirmBtn) {
|
||||||
|
moveConfirmBtn.addEventListener("click", async () => {
|
||||||
|
const itemId = document.getElementById("move-item").value;
|
||||||
|
const from = document.getElementById("move-from").value;
|
||||||
|
const to = document.getElementById("move-to").value;
|
||||||
|
|
||||||
|
if (!itemId || !to) {
|
||||||
|
alert("Select an item and target column");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === to) {
|
||||||
|
alert("Item is already in that column");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ state: to }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadBoard();
|
||||||
|
} else {
|
||||||
|
alert("Failed to move competency");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedItemId = null;
|
||||||
|
|
||||||
|
function countByState(items) {
|
||||||
|
return items.reduce((acc, item) => {
|
||||||
|
acc[item.state] = (acc[item.state] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProgress(items) {
|
||||||
|
const total = items.length;
|
||||||
|
const completed = items.filter(i => i.state === "completed").length;
|
||||||
|
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||||
|
|
||||||
|
const fill = document.getElementById("progress-fill");
|
||||||
|
const label = document.getElementById("progress-label");
|
||||||
|
|
||||||
|
if (fill) fill.style.width = `${percent}%`;
|
||||||
|
if (label) label.textContent = `${percent}% completed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(item) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "kanban-card";
|
||||||
|
card.draggable = true;
|
||||||
|
card.dataset.id = item.id;
|
||||||
|
card.textContent = item.title;
|
||||||
|
|
||||||
|
card.addEventListener("dragstart", () => {
|
||||||
|
draggedItemId = item.id;
|
||||||
|
card.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("dragend", () => {
|
||||||
|
draggedItemId = null;
|
||||||
|
card.classList.remove("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createColumn(state, items, counts) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "kanban-column";
|
||||||
|
col.dataset.state = state.key;
|
||||||
|
|
||||||
|
const header = document.createElement("h3");
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="kanban-title">${state.label}</span>
|
||||||
|
<span class="kanban-count">${counts[state.key] || 0}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "kanban-list";
|
||||||
|
|
||||||
|
list.addEventListener("dragover", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
list.addEventListener("drop", async () => {
|
||||||
|
if (!draggedItemId) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ state: state.key }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadBoard();
|
||||||
|
} else {
|
||||||
|
alert("Failed to update state");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items
|
||||||
|
.filter((i) => i.state === state.key)
|
||||||
|
.forEach((i) => list.appendChild(createCard(i)));
|
||||||
|
|
||||||
|
col.appendChild(header);
|
||||||
|
col.appendChild(list);
|
||||||
|
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard(items) {
|
||||||
|
const board = document.getElementById("kanban-board");
|
||||||
|
if (!board) return;
|
||||||
|
board.innerHTML = "";
|
||||||
|
|
||||||
|
const counts = countByState(items);
|
||||||
|
|
||||||
|
STATES.forEach((state) => {
|
||||||
|
board.appendChild(createColumn(state, items, counts));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoard() {
|
||||||
|
const res = await fetch(`/api/competencies/items?group=${currentLevel}`);
|
||||||
|
const items = await res.json();
|
||||||
|
|
||||||
|
updateProgress(items);
|
||||||
|
renderBoard(items);
|
||||||
|
populateMobileControls(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLevelTabs();
|
||||||
|
loadBoard();
|
||||||
313
assets/scripts/pages/home-dashboard.js
Normal file
313
assets/scripts/pages/home-dashboard.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/* Home dashboard behaviour. All selectors are db-* scoped. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function updateGreeting() {
|
||||||
|
const el = document.getElementById("db-greeting");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 5) el.textContent = "Late session";
|
||||||
|
else if (hour < 12) el.textContent = "Good morning";
|
||||||
|
else if (hour < 17) el.textContent = "Good afternoon";
|
||||||
|
else if (hour < 21) el.textContent = "Good evening";
|
||||||
|
else el.textContent = "Evening review";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClock() {
|
||||||
|
const el = document.getElementById("db-clock");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.textContent = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIsoWeek(date) {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const start = new Date(year, 0, 1);
|
||||||
|
const nextYear = new Date(year + 1, 0, 1);
|
||||||
|
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
|
||||||
|
const daysInYear = Math.round((nextYear - start) / 86400000);
|
||||||
|
const daysLeft = Math.max(0, daysInYear - dayOfYear);
|
||||||
|
const month = new Intl.DateTimeFormat(undefined, { month: "short" }).format(now);
|
||||||
|
const pct = ((dayOfYear / daysInYear) * 100).toFixed(1);
|
||||||
|
|
||||||
|
setText("db-stat-day", dayOfYear);
|
||||||
|
setText("db-stat-week", "W" + getIsoWeek(now));
|
||||||
|
setText("db-stat-month", month);
|
||||||
|
setText("db-stat-left", daysLeft);
|
||||||
|
setText("db-year-pct", pct + "%");
|
||||||
|
|
||||||
|
const fill = document.getElementById("db-year-fill");
|
||||||
|
if (fill) fill.style.width = pct + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(str) {
|
||||||
|
return escHtml(str).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseHref(href, prefix) {
|
||||||
|
if (!href || href.startsWith("http") || href.startsWith("#")) return null;
|
||||||
|
if (href.startsWith("/")) return href;
|
||||||
|
const base = (prefix || "").replace(/\/$/, "");
|
||||||
|
if (!base || href.startsWith(base + "/")) return href;
|
||||||
|
return base + "/" + href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTagLink(a) {
|
||||||
|
const href = a.getAttribute("href") || "";
|
||||||
|
return href.includes("/tags/") || Boolean(a.querySelector(".post-tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLinks(doc, max, prefix) {
|
||||||
|
const items = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const links = doc.querySelectorAll("#content li a[href], .org-ul li a[href], ul li a[href]");
|
||||||
|
|
||||||
|
for (const a of links) {
|
||||||
|
if (isTagLink(a)) continue;
|
||||||
|
|
||||||
|
const href = normaliseHref(a.getAttribute("href"), prefix);
|
||||||
|
const title = a.textContent.trim();
|
||||||
|
if (!href || !title || seen.has(href)) continue;
|
||||||
|
|
||||||
|
const li = a.closest("li");
|
||||||
|
const dateMatch = li ? li.textContent.match(/\b\d{4}-\d{2}-\d{2}\b/) : null;
|
||||||
|
seen.add(href);
|
||||||
|
items.push({ href, title, date: dateMatch ? dateMatch[0] : null });
|
||||||
|
if (items.length >= max) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeed(ulId, items) {
|
||||||
|
const ul = document.getElementById(ulId);
|
||||||
|
if (!ul || !items.length) return;
|
||||||
|
|
||||||
|
ul.innerHTML = items
|
||||||
|
.map(({ href, title, date }) => (
|
||||||
|
`<li class="db-feed__item">` +
|
||||||
|
`<span class="db-feed__dot"></span>` +
|
||||||
|
`<a href="${escHtml(href)}">${escHtml(title)}</a>` +
|
||||||
|
(date ? `<span class="db-feed__meta">${escHtml(date)}</span>` : "") +
|
||||||
|
`</li>`
|
||||||
|
))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeed(listUrl, ulId, max, prefix) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(listUrl, { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
|
||||||
|
const html = await resp.text();
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
renderFeed(ulId, extractLinks(doc, max, prefix));
|
||||||
|
} catch (_) {
|
||||||
|
/* Fallback links remain in the HTML. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentSlug(comment) {
|
||||||
|
return comment.pageSlug || comment.page_slug || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentDate(comment) {
|
||||||
|
return comment.created_at || comment.createdAt || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentHref(page, comment) {
|
||||||
|
if (!page?.url) return null;
|
||||||
|
if (!comment.id) return page.url + "#comments";
|
||||||
|
return page.url + "#comment-" + encodeURIComponent(comment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentDate(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentExcerpt(content) {
|
||||||
|
const text = String(content || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (text.length <= 150) return text;
|
||||||
|
return text.slice(0, 147).trimEnd() + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentComments(comments, pageMap) {
|
||||||
|
const ul = document.getElementById("db-feed-comments");
|
||||||
|
if (!ul) return;
|
||||||
|
|
||||||
|
if (!comments.length) {
|
||||||
|
ul.innerHTML = (
|
||||||
|
`<li class="db-comment-feed__item">` +
|
||||||
|
`<span class="db-feed__dot"></span>` +
|
||||||
|
`<div class="db-comment-feed__body">` +
|
||||||
|
`<span class="db-comment-feed__empty">No comments yet.</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`</li>`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.innerHTML = comments.map((comment) => {
|
||||||
|
const slug = commentSlug(comment);
|
||||||
|
const page = pageMap.get(slug);
|
||||||
|
const title = page?.title || slug || "Unknown page";
|
||||||
|
const href = commentHref(page, comment);
|
||||||
|
const author = comment.author || "Anonymous";
|
||||||
|
const date = formatCommentDate(commentDate(comment));
|
||||||
|
const pageLink = href
|
||||||
|
? `<a class="db-comment-feed__page" href="${escAttr(href)}">${escHtml(title)}</a>`
|
||||||
|
: `<span class="db-comment-feed__page">${escHtml(title)}</span>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<li class="db-comment-feed__item">` +
|
||||||
|
`<span class="db-feed__dot"></span>` +
|
||||||
|
`<div class="db-comment-feed__body">` +
|
||||||
|
`<div class="db-comment-feed__top">` +
|
||||||
|
`<strong>${escHtml(author)}</strong>` +
|
||||||
|
`<span>on</span>` +
|
||||||
|
pageLink +
|
||||||
|
(date ? `<time datetime="${escAttr(commentDate(comment))}">${escHtml(date)}</time>` : "") +
|
||||||
|
`</div>` +
|
||||||
|
`<p>${escHtml(commentExcerpt(comment.content))}</p>` +
|
||||||
|
`</div>` +
|
||||||
|
`</li>`
|
||||||
|
);
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommentPageMap() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/assets/content/comment-pages.json", { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) return new Map();
|
||||||
|
|
||||||
|
const pages = await resp.json();
|
||||||
|
return new Map(
|
||||||
|
pages
|
||||||
|
.filter((page) => page.slug && page.url)
|
||||||
|
.map((page) => [page.slug, page])
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentComments() {
|
||||||
|
const ul = document.getElementById("db-feed-comments");
|
||||||
|
if (!ul) return;
|
||||||
|
|
||||||
|
const renderUnavailable = () => {
|
||||||
|
ul.innerHTML = (
|
||||||
|
`<li class="db-comment-feed__item">` +
|
||||||
|
`<span class="db-feed__dot"></span>` +
|
||||||
|
`<div class="db-comment-feed__body">` +
|
||||||
|
`<span class="db-comment-feed__empty">Recent comments are unavailable.</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`</li>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [pageMap, resp] = await Promise.all([
|
||||||
|
loadCommentPageMap(),
|
||||||
|
fetch("/api/comments", { credentials: "same-origin" }),
|
||||||
|
]);
|
||||||
|
if (!resp.ok) {
|
||||||
|
renderUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await resp.json();
|
||||||
|
const recent = comments
|
||||||
|
.filter((comment) => commentDate(comment))
|
||||||
|
.sort((a, b) => new Date(commentDate(b)) - new Date(commentDate(a)))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
renderRecentComments(recent, pageMap);
|
||||||
|
} catch (_) {
|
||||||
|
renderUnavailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCommandFilter() {
|
||||||
|
const input = document.getElementById("db-command-search");
|
||||||
|
const nav = document.getElementById("db-quicknav");
|
||||||
|
if (!input || !nav) return;
|
||||||
|
|
||||||
|
const items = Array.from(nav.querySelectorAll(".db-qn-item"));
|
||||||
|
const applyFilter = () => {
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const haystack = [
|
||||||
|
item.textContent,
|
||||||
|
item.getAttribute("href"),
|
||||||
|
item.dataset.keywords,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
item.classList.toggle("is-hidden", Boolean(query && !haystack.includes(query)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", applyFilter);
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "/" && !/^(input|textarea|select)$/i.test(event.target.tagName)) {
|
||||||
|
event.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
updateGreeting();
|
||||||
|
updateClock();
|
||||||
|
updateStats();
|
||||||
|
initCommandFilter();
|
||||||
|
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
loadFeed("/blogs/blogs-list.html", "db-feed-blogs", 6, "/blogs");
|
||||||
|
loadFeed("/posts/posts-list.html", "db-feed-posts", 6, "/posts");
|
||||||
|
loadFeed("/recently-updated.html", "db-feed-recent", 6, "");
|
||||||
|
loadRecentComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
148
assets/scripts/pages/notes.js
Normal file
148
assets/scripts/pages/notes.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
(function () {
|
||||||
|
const wall = document.getElementById("notes-wall");
|
||||||
|
const form = document.getElementById("notes-form");
|
||||||
|
const authorInput = document.getElementById("note-author");
|
||||||
|
const contentInput = document.getElementById("note-content");
|
||||||
|
const authorFilter = document.getElementById("notes-author-filter");
|
||||||
|
let notesCache = [];
|
||||||
|
|
||||||
|
if (!wall) return;
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNote(note) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "note";
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="note-meta">
|
||||||
|
<span class="note-author">${escapeHtml(note.author_name)}</span>
|
||||||
|
<time datetime="${note.created_at}">
|
||||||
|
${new Date(note.created_at).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<div class="note-content">
|
||||||
|
${escapeHtml(note.content).replace(/\n/g, "<br>")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseAuthor(author) {
|
||||||
|
return (author || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAuthorFilter(notes) {
|
||||||
|
if (!authorFilter) return;
|
||||||
|
|
||||||
|
const previousValue = authorFilter.value;
|
||||||
|
const authors = Array.from(
|
||||||
|
new Set(notes.map(note => normaliseAuthor(note.author_name)).filter(Boolean))
|
||||||
|
).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||||
|
|
||||||
|
authorFilter.innerHTML = '<option value="">All authors</option>';
|
||||||
|
|
||||||
|
authors.forEach(author => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = author;
|
||||||
|
option.textContent = author;
|
||||||
|
authorFilter.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
authorFilter.value = authors.includes(previousValue) ? previousValue : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes() {
|
||||||
|
wall.innerHTML = "";
|
||||||
|
|
||||||
|
const selectedAuthor = authorFilter ? authorFilter.value : "";
|
||||||
|
const notes = selectedAuthor
|
||||||
|
? notesCache.filter(note => normaliseAuthor(note.author_name) === selectedAuthor)
|
||||||
|
: notesCache;
|
||||||
|
|
||||||
|
if (notesCache.length === 0) {
|
||||||
|
wall.innerHTML = "<p>No notes yet.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
wall.innerHTML = "<p>No notes for this author.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.forEach(note => {
|
||||||
|
wall.appendChild(renderNote(note));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
wall.innerHTML = "<p>Loading notes…</p>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notes");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch notes");
|
||||||
|
|
||||||
|
const notes = await res.json();
|
||||||
|
notesCache = notes;
|
||||||
|
populateAuthorFilter(notesCache);
|
||||||
|
renderNotes();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
wall.innerHTML = "<p>Could not load notes.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNote(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const author = authorInput.value.trim();
|
||||||
|
const content = contentInput.value.trim();
|
||||||
|
|
||||||
|
if (!author || !content) return;
|
||||||
|
|
||||||
|
form.querySelector("button").disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
author_name: author,
|
||||||
|
content: content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || "Failed to post note");
|
||||||
|
}
|
||||||
|
|
||||||
|
contentInput.value = "";
|
||||||
|
await loadNotes();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
form.querySelector("button").disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", submitNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorFilter) {
|
||||||
|
authorFilter.addEventListener("change", renderNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
})();
|
||||||
866
assets/scripts/pages/play.js
Normal file
866
assets/scripts/pages/play.js
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
(function () {
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value);
|
||||||
|
const quips = [
|
||||||
|
"A margin note blinks, then pretends it did not.",
|
||||||
|
"Somewhere, a bookmark changes its mind.",
|
||||||
|
"The archive is pleased by unnecessary curiosity.",
|
||||||
|
"A tiny bell rings in a room you have not built yet.",
|
||||||
|
"The page remembers that you came here."
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const root = $(".play-root[data-play-page]");
|
||||||
|
if (!root) return;
|
||||||
|
const page = root.dataset.playPage;
|
||||||
|
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
|
||||||
|
if (inits[page]) inits[page](root);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hub(root) {
|
||||||
|
const links = $$(".play-grid a", root);
|
||||||
|
const nodes = $$(".play-orbit__node", root);
|
||||||
|
const title = $("#play-hub-title", root);
|
||||||
|
const kind = $("#play-hub-kind", root);
|
||||||
|
const desc = $("#play-hub-desc", root);
|
||||||
|
const cta = $("#play-hub-link", root);
|
||||||
|
const whisper = document.createElement("p");
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
whisper.className = "play-whisper";
|
||||||
|
whisper.setAttribute("aria-live", "polite");
|
||||||
|
$(".play-console__screen", root).appendChild(whisper);
|
||||||
|
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
node.style.left = `${50 + Math.cos(angle) * 43}%`;
|
||||||
|
node.style.top = `${50 + Math.sin(angle) * 43}%`;
|
||||||
|
node.addEventListener("click", () => setActive(index));
|
||||||
|
});
|
||||||
|
|
||||||
|
function setActive(index) {
|
||||||
|
active = (index + links.length) % links.length;
|
||||||
|
const link = links[active];
|
||||||
|
links.forEach((item, i) => item.classList.toggle("is-active", i === active));
|
||||||
|
nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
|
||||||
|
title.textContent = link.querySelector("strong").textContent;
|
||||||
|
kind.textContent = link.dataset.kind;
|
||||||
|
desc.textContent = link.dataset.desc;
|
||||||
|
cta.href = link.href;
|
||||||
|
whisper.textContent = quips[active % quips.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
link.addEventListener("mouseenter", () => setActive(index));
|
||||||
|
link.addEventListener("focus", () => setActive(index));
|
||||||
|
});
|
||||||
|
$("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
|
||||||
|
$("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
|
||||||
|
$("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
|
||||||
|
$(".play-orbit", root).addEventListener("dblclick", () => {
|
||||||
|
whisper.textContent = "You knocked twice. The cabinet knocked once back.";
|
||||||
|
root.classList.toggle("is-odd");
|
||||||
|
});
|
||||||
|
root.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "ArrowLeft") setActive(active - 1);
|
||||||
|
if (event.key === "ArrowRight") setActive(active + 1);
|
||||||
|
});
|
||||||
|
setActive(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function memory(root) {
|
||||||
|
const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
|
||||||
|
const board = $("[data-memory-board]", root);
|
||||||
|
const turnsEl = $("[data-memory-turns]", root);
|
||||||
|
const matchesEl = $("[data-memory-matches]", root);
|
||||||
|
const messageEl = $("[data-memory-message]", root);
|
||||||
|
let open = [];
|
||||||
|
let turns = 0;
|
||||||
|
let matches = 0;
|
||||||
|
let deck = [];
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
board.innerHTML = "";
|
||||||
|
open = [];
|
||||||
|
turns = 0;
|
||||||
|
matches = 0;
|
||||||
|
deck = shuffle([...symbols, ...symbols]);
|
||||||
|
turnsEl.textContent = "0";
|
||||||
|
matchesEl.textContent = "0";
|
||||||
|
messageEl.textContent = "Cards are shuffled.";
|
||||||
|
deck.forEach((symbol, index) => {
|
||||||
|
const card = document.createElement("button");
|
||||||
|
card.type = "button";
|
||||||
|
card.className = "memory-card";
|
||||||
|
card.dataset.symbol = symbol;
|
||||||
|
card.textContent = "?";
|
||||||
|
card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
|
||||||
|
card.setAttribute("aria-label", "Hidden card");
|
||||||
|
card.addEventListener("click", () => flip(card));
|
||||||
|
board.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flip(card) {
|
||||||
|
if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
|
||||||
|
card.classList.add("is-open");
|
||||||
|
card.textContent = card.dataset.symbol;
|
||||||
|
card.setAttribute("aria-label", card.dataset.symbol);
|
||||||
|
open.push(card);
|
||||||
|
if (open.length !== 2) return;
|
||||||
|
turns += 1;
|
||||||
|
turnsEl.textContent = String(turns);
|
||||||
|
const [a, b] = open;
|
||||||
|
if (a.dataset.symbol === b.dataset.symbol) {
|
||||||
|
a.classList.add("is-matched");
|
||||||
|
b.classList.add("is-matched");
|
||||||
|
open = [];
|
||||||
|
matches += 1;
|
||||||
|
matchesEl.textContent = String(matches);
|
||||||
|
messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
|
||||||
|
} else {
|
||||||
|
messageEl.textContent = "No match. The cabinet quietly re-files them.";
|
||||||
|
setTimeout(() => {
|
||||||
|
a.classList.remove("is-open");
|
||||||
|
b.classList.remove("is-open");
|
||||||
|
a.textContent = "?";
|
||||||
|
b.textContent = "?";
|
||||||
|
a.setAttribute("aria-label", "Hidden card");
|
||||||
|
b.setAttribute("aria-label", "Hidden card");
|
||||||
|
open = [];
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("[data-memory-reset]", root).addEventListener("click", render);
|
||||||
|
$("[data-memory-peek]", root).addEventListener("click", () => {
|
||||||
|
const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
|
||||||
|
hidden.forEach((card) => {
|
||||||
|
card.textContent = card.dataset.symbol;
|
||||||
|
card.classList.add("is-peeking");
|
||||||
|
});
|
||||||
|
messageEl.textContent = "A very brief lapse in scholarly discipline.";
|
||||||
|
setTimeout(() => {
|
||||||
|
hidden.forEach((card) => {
|
||||||
|
if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
|
||||||
|
card.classList.remove("is-peeking");
|
||||||
|
});
|
||||||
|
}, 900);
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function constellation(root) {
|
||||||
|
const canvas = $("[data-star-canvas]", root);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const linesEl = $("[data-star-lines]", root);
|
||||||
|
const nameEl = document.createElement("strong");
|
||||||
|
const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
|
||||||
|
const lines = [];
|
||||||
|
let selected = null;
|
||||||
|
nameEl.className = "play-live-note";
|
||||||
|
linesEl.closest(".play-scorebar").appendChild(nameEl);
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = "#18130f";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
lines.forEach(([a, b]) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(stars[a].x, stars[a].y);
|
||||||
|
ctx.lineTo(stars[b].x, stars[b].y);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
stars.forEach((star, index) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
|
||||||
|
ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
linesEl.textContent = String(lines.length);
|
||||||
|
if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("click", (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
|
||||||
|
const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
||||||
|
const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
|
||||||
|
if (hit < 0) return;
|
||||||
|
if (selected === null) selected = hit;
|
||||||
|
else if (selected !== hit) {
|
||||||
|
lines.push([selected, hit]);
|
||||||
|
selected = hit;
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
$("[data-star-reset]", root).addEventListener("click", () => {
|
||||||
|
lines.length = 0;
|
||||||
|
selected = null;
|
||||||
|
nameEl.textContent = "";
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function poem(root) {
|
||||||
|
const output = $("[data-poem-output]", root);
|
||||||
|
const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
|
||||||
|
const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
|
||||||
|
const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
|
||||||
|
let count = 0;
|
||||||
|
function make() {
|
||||||
|
count += 1;
|
||||||
|
output.innerHTML = `${pick(a)}<br>${pick(b)}<br>${pick(c)}`;
|
||||||
|
output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
|
||||||
|
}
|
||||||
|
$("[data-poem-generate]", root).addEventListener("click", make);
|
||||||
|
make();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookshelf(root) {
|
||||||
|
const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
|
||||||
|
const shelf = $("[data-bookshelf]", root);
|
||||||
|
const status = $("[data-bookshelf-status]", root);
|
||||||
|
let sortedOnce = false;
|
||||||
|
setupSortable(shelf, render, check);
|
||||||
|
$("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
|
||||||
|
render(shuffle(titles));
|
||||||
|
|
||||||
|
function render(items) {
|
||||||
|
shelf.innerHTML = "";
|
||||||
|
items.forEach((title) => {
|
||||||
|
const book = document.createElement("div");
|
||||||
|
book.className = "book-spine";
|
||||||
|
book.draggable = true;
|
||||||
|
book.dataset.value = title;
|
||||||
|
book.textContent = title;
|
||||||
|
shelf.appendChild(book);
|
||||||
|
});
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
function check() {
|
||||||
|
const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
|
||||||
|
const sorted = current.join("|") === titles.join("|");
|
||||||
|
status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
|
||||||
|
if (sorted && !sortedOnce) {
|
||||||
|
sortedOnce = true;
|
||||||
|
const pamphlet = document.createElement("div");
|
||||||
|
pamphlet.className = "booklet";
|
||||||
|
pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
|
||||||
|
shelf.appendChild(pamphlet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe(root) {
|
||||||
|
const wheel = $("[data-recipe-spin]", root);
|
||||||
|
const result = $("[data-recipe-result]", root);
|
||||||
|
const groups = [
|
||||||
|
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
|
||||||
|
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
|
||||||
|
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
|
||||||
|
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
|
||||||
|
];
|
||||||
|
let spin = 0;
|
||||||
|
let feast = 0;
|
||||||
|
wheel.addEventListener("click", () => {
|
||||||
|
spin += 540 + Math.floor(Math.random() * 540);
|
||||||
|
feast += 1;
|
||||||
|
wheel.style.transform = `rotate(${spin}deg)`;
|
||||||
|
result.innerHTML = groups.map((group) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
|
||||||
|
});
|
||||||
|
wheel.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeline(root) {
|
||||||
|
const items = [
|
||||||
|
{ year: "2022", text: "The personal web habit begins." },
|
||||||
|
{ year: "2025", text: "Org publishing becomes the main site engine." },
|
||||||
|
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
|
||||||
|
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
|
||||||
|
{ year: "2026", text: "The play wing opens." }
|
||||||
|
];
|
||||||
|
const list = $("[data-timeline-list]", root);
|
||||||
|
const status = $("[data-timeline-status]", root);
|
||||||
|
const paradox = document.createElement("button");
|
||||||
|
paradox.type = "button";
|
||||||
|
paradox.textContent = "Paradox";
|
||||||
|
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
|
||||||
|
setupSortable(list, render, check);
|
||||||
|
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
|
||||||
|
paradox.addEventListener("click", () => render([...items].reverse()));
|
||||||
|
render(shuffle(items));
|
||||||
|
|
||||||
|
function render(source) {
|
||||||
|
list.innerHTML = "";
|
||||||
|
source.forEach((item, index) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "timeline-card";
|
||||||
|
card.draggable = true;
|
||||||
|
card.dataset.value = String(items.indexOf(item));
|
||||||
|
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
function check() {
|
||||||
|
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
|
||||||
|
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ink(root) {
|
||||||
|
const canvas = $("[data-ink-canvas]", root);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const drops = [];
|
||||||
|
let moon = false;
|
||||||
|
function addDrop(x, y, heavy) {
|
||||||
|
drops.push({ x, y, r: heavy ? 18 : 8, life: heavy ? 1.8 : 1, hue: 28 + Math.random() * 35 });
|
||||||
|
}
|
||||||
|
function pointer(event, heavy) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
addDrop(((event.clientX - rect.left) / rect.width) * canvas.width, ((event.clientY - rect.top) / rect.height) * canvas.height, heavy);
|
||||||
|
}
|
||||||
|
canvas.addEventListener("pointermove", (event) => pointer(event, false));
|
||||||
|
canvas.addEventListener("click", (event) => pointer(event, true));
|
||||||
|
canvas.addEventListener("dblclick", () => {
|
||||||
|
moon = !moon;
|
||||||
|
});
|
||||||
|
function frame() {
|
||||||
|
ctx.fillStyle = "rgba(24, 19, 15, 0.16)";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (moon) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = "rgba(246, 216, 137, 0.72)";
|
||||||
|
ctx.arc(760, 88, 34, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
drops.forEach((drop) => {
|
||||||
|
drop.r += 0.7;
|
||||||
|
drop.life -= 0.012;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = `hsla(${drop.hue}, 65%, 68%, ${Math.max(drop.life, 0)})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.arc(drop.x, drop.y, drop.r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
for (let i = drops.length - 1; i >= 0; i -= 1) if (drops[i].life <= 0) drops.splice(i, 1);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminal(root) {
|
||||||
|
const log = $("[data-terminal-log]", root);
|
||||||
|
const form = $("[data-terminal-form]", root);
|
||||||
|
const input = $("[data-terminal-input]", root);
|
||||||
|
let kindness = 0;
|
||||||
|
const responses = {
|
||||||
|
help: "Commands: help, look, map, open drawer, read note, brew tea, hum, knock, save, inventory, clear",
|
||||||
|
look: "A narrow archive room. A lamp hums. A drawer is labelled MAYBE IMPORTANT.",
|
||||||
|
map: "You are between the reading desk, the family shelf, and the door back to Play.",
|
||||||
|
"open drawer": "Inside: a brass key, a receipt, and a note folded twice.",
|
||||||
|
"read note": "The note says: keep the site useful, but leave a few doors ajar.",
|
||||||
|
"brew tea": "The room smells briefly of cardamom. Nothing else changes, which is enough.",
|
||||||
|
hum: "You hum four careful notes. Something behind the wall hums five back.",
|
||||||
|
knock: "Knock. Knock. ... A polite pause. Knock.",
|
||||||
|
save: "You save your place in the archive. The archive saves its place in you.",
|
||||||
|
inventory: "You are carrying: a brass key, a warm cup, and one unreasonable hope."
|
||||||
|
};
|
||||||
|
function write(text) {
|
||||||
|
log.textContent += `${text}\n`;
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const cmd = input.value.trim().toLowerCase();
|
||||||
|
if (!cmd) return;
|
||||||
|
input.value = "";
|
||||||
|
if (cmd === "clear") {
|
||||||
|
log.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
write(`> ${cmd}`);
|
||||||
|
if (cmd === "pet book") {
|
||||||
|
kindness += 1;
|
||||||
|
write(kindness > 2 ? "The book follows you for exactly three pages." : "The book accepts this with suspicious dignity.");
|
||||||
|
} else {
|
||||||
|
write(responses[cmd] || "The archive declines to understand that command.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
write("Archive terminal ready. Type help. The cursor is listening.");
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function study(root) {
|
||||||
|
const time = $("[data-study-time]", root);
|
||||||
|
const scene = $("[data-study-scene]", root);
|
||||||
|
let total = 300;
|
||||||
|
let left = total;
|
||||||
|
let timer = null;
|
||||||
|
const note = document.createElement("div");
|
||||||
|
note.className = "play-live-note";
|
||||||
|
note.textContent = "The lamp is cold.";
|
||||||
|
time.insertAdjacentElement("afterend", note);
|
||||||
|
function draw() {
|
||||||
|
const done = 1 - left / total;
|
||||||
|
scene.style.setProperty("--lamp", String(0.18 + done * 0.72));
|
||||||
|
scene.style.setProperty("--glow", `${done * 70}px`);
|
||||||
|
const m = String(Math.floor(left / 60)).padStart(2, "0");
|
||||||
|
const s = String(left % 60).padStart(2, "0");
|
||||||
|
time.textContent = `${m}:${s}`;
|
||||||
|
if (left === 0) note.textContent = "Focus complete. The desk looks proud.";
|
||||||
|
else if (done > 0.66) note.textContent = "The page has warmed through.";
|
||||||
|
else if (done > 0.33) note.textContent = "The lamp has settled into its work.";
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setInterval(() => {
|
||||||
|
left = Math.max(0, left - 1);
|
||||||
|
draw();
|
||||||
|
if (left === 0) pause();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
function pause() {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
$("[data-study-start]", root).addEventListener("click", start);
|
||||||
|
$("[data-study-pause]", root).addEventListener("click", pause);
|
||||||
|
$("[data-study-reset]", root).addEventListener("click", () => {
|
||||||
|
pause();
|
||||||
|
left = total;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sigil(root) {
|
||||||
|
const canvas = $("[data-sigil-canvas]", root);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const initials = $("[data-sigil-initials]", root);
|
||||||
|
const motto = $("[data-sigil-motto]", root);
|
||||||
|
let wax = "#9c6b2f";
|
||||||
|
const waxButton = document.createElement("button");
|
||||||
|
waxButton.type = "button";
|
||||||
|
waxButton.textContent = "New wax";
|
||||||
|
$("[data-sigil-form]", root).appendChild(waxButton);
|
||||||
|
function draw() {
|
||||||
|
const text = (initials.value || "ZXH").toUpperCase();
|
||||||
|
const phrase = motto.value || "Learn, make, remember";
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = "#fbf7ec";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.translate(260, 260);
|
||||||
|
ctx.strokeStyle = wax;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 190, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
ctx.rotate(Math.PI / 6);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -150);
|
||||||
|
ctx.lineTo(0, -185);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.rotate(-Math.PI * 2);
|
||||||
|
ctx.fillStyle = "#2a2118";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.font = "900 94px Georgia, serif";
|
||||||
|
ctx.fillText(text.slice(0, 4), 0, -10);
|
||||||
|
ctx.font = "700 22px Georgia, serif";
|
||||||
|
ctx.fillText(phrase.slice(0, 34), 0, 88);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
waxButton.addEventListener("click", () => {
|
||||||
|
wax = pick(["#9c6b2f", "#8a3f3f", "#486b57", "#3e5f8a", "#5f4b8b"]);
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
$("[data-sigil-form]", root).addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
initials.addEventListener("input", draw);
|
||||||
|
motto.addEventListener("input", draw);
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpg(root) {
|
||||||
|
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
|
||||||
|
const canvas = $("[data-rpg-canvas]", root);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const els = {
|
||||||
|
speaker: $("[data-rpg-speaker]", root),
|
||||||
|
line: $("[data-rpg-line]", root),
|
||||||
|
room: $("[data-rpg-room]", root),
|
||||||
|
route: $("[data-rpg-route]", root),
|
||||||
|
hearts: $("[data-rpg-hearts]", root),
|
||||||
|
quests: $("[data-rpg-quests]", root),
|
||||||
|
inventory: $("[data-rpg-inventory]", root),
|
||||||
|
status: $("[data-rpg-status]", root)
|
||||||
|
};
|
||||||
|
const tile = 32;
|
||||||
|
const saveSlot = "hollow-archive";
|
||||||
|
const fallbackKey = "play:rpg:hollow-archive";
|
||||||
|
const items = {
|
||||||
|
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
|
||||||
|
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
|
||||||
|
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
|
||||||
|
};
|
||||||
|
const npcs = {
|
||||||
|
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
|
||||||
|
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
|
||||||
|
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
|
||||||
|
};
|
||||||
|
const rooms = {
|
||||||
|
entrance: {
|
||||||
|
name: "Entrance",
|
||||||
|
floor: "#353029",
|
||||||
|
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
|
||||||
|
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
|
||||||
|
},
|
||||||
|
stacks: {
|
||||||
|
name: "Stacks",
|
||||||
|
floor: "#242d35",
|
||||||
|
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
|
||||||
|
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
|
||||||
|
},
|
||||||
|
garden: {
|
||||||
|
name: "Moon Garden",
|
||||||
|
floor: "#21362e",
|
||||||
|
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
|
||||||
|
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let state = freshState();
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
function freshState() {
|
||||||
|
return {
|
||||||
|
room: "entrance",
|
||||||
|
player: { x: 3, y: 7, facing: "down" },
|
||||||
|
inventory: [],
|
||||||
|
flags: {},
|
||||||
|
route: "Undecided",
|
||||||
|
hearts: 3,
|
||||||
|
ending: null,
|
||||||
|
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rects(sources) {
|
||||||
|
const set = new Set();
|
||||||
|
sources.forEach(([x, y, w, h]) => {
|
||||||
|
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const room = rooms[state.room];
|
||||||
|
ctx.fillStyle = "#14110e";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let y = 0; y < 16; y += 1) {
|
||||||
|
for (let x = 0; x < 24; x += 1) {
|
||||||
|
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
|
||||||
|
ctx.fillRect(x * tile, y * tile, tile, tile);
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.035)";
|
||||||
|
ctx.strokeRect(x * tile, y * tile, tile, tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
|
||||||
|
Object.entries(items).forEach(([id, item]) => {
|
||||||
|
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
|
||||||
|
});
|
||||||
|
Object.values(npcs).forEach((npc) => {
|
||||||
|
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
|
||||||
|
});
|
||||||
|
drawPlayer();
|
||||||
|
renderHud();
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGlyph(x, y, color, kind) {
|
||||||
|
const px = x * tile;
|
||||||
|
const py = y * tile;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
if (kind === "item") {
|
||||||
|
ctx.fillRect(px + 10, py + 10, 12, 12);
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||||
|
ctx.fillRect(px + 13, py + 7, 6, 6);
|
||||||
|
} else if (kind === "door" || kind === "doorNpc") {
|
||||||
|
ctx.fillRect(px + 7, py + 4, 18, 25);
|
||||||
|
ctx.fillStyle = "#211812";
|
||||||
|
ctx.fillRect(px + 20, py + 16, 3, 3);
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillRect(px + 8, py + 20, 16, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlayer() {
|
||||||
|
const px = state.player.x * tile;
|
||||||
|
const py = state.player.y * tile;
|
||||||
|
ctx.fillStyle = "#f35f5f";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + 16, py + 7);
|
||||||
|
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
|
||||||
|
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "#fff8e8";
|
||||||
|
ctx.fillRect(px + 11, py + 13, 4, 4);
|
||||||
|
ctx.fillRect(px + 18, py + 13, 4, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHud() {
|
||||||
|
els.speaker.textContent = state.message.speaker;
|
||||||
|
els.line.textContent = state.ending ? endingLine() : state.message.line;
|
||||||
|
els.room.textContent = rooms[state.room].name;
|
||||||
|
els.route.textContent = state.route;
|
||||||
|
els.hearts.textContent = String(state.hearts);
|
||||||
|
els.inventory.innerHTML = state.inventory.length
|
||||||
|
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
|
||||||
|
: "<li>Empty</li>";
|
||||||
|
const questRows = [
|
||||||
|
["Find a light", state.inventory.includes("lamp")],
|
||||||
|
["Return the loose page to the shade", state.flags.shadeHelped],
|
||||||
|
["Open, force, or leave the iron door", Boolean(state.ending)]
|
||||||
|
];
|
||||||
|
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(dx, dy, facing) {
|
||||||
|
if (!running || state.ending) return;
|
||||||
|
state.player.facing = facing;
|
||||||
|
const nx = state.player.x + dx;
|
||||||
|
const ny = state.player.y + dy;
|
||||||
|
const room = rooms[state.room];
|
||||||
|
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
|
||||||
|
if (exit) {
|
||||||
|
state.room = exit.to;
|
||||||
|
state.player.x = exit.px;
|
||||||
|
state.player.y = exit.py;
|
||||||
|
say("Narrator", `You enter ${rooms[state.room].name}.`);
|
||||||
|
autosave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
|
||||||
|
state.player.x = nx;
|
||||||
|
state.player.y = ny;
|
||||||
|
const item = itemAt(nx, ny);
|
||||||
|
if (item) take(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function act() {
|
||||||
|
if (!running) return start();
|
||||||
|
if (state.ending) return;
|
||||||
|
const front = inFront();
|
||||||
|
const npc = npcAt(front.x, front.y);
|
||||||
|
if (npc) talk(npc);
|
||||||
|
else {
|
||||||
|
const here = itemAt(state.player.x, state.player.y);
|
||||||
|
if (here) take(here);
|
||||||
|
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
|
||||||
|
}
|
||||||
|
autosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFront() {
|
||||||
|
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
|
||||||
|
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAt(x, y) {
|
||||||
|
return Object.keys(items).find((id) => {
|
||||||
|
const item = items[id];
|
||||||
|
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function npcAt(x, y) {
|
||||||
|
return Object.keys(npcs).find((id) => {
|
||||||
|
const npc = npcs[id];
|
||||||
|
return npc.room === state.room && npc.x === x && npc.y === y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function take(id) {
|
||||||
|
state.inventory.push(id);
|
||||||
|
if (id === "lamp") state.route = "Gentle";
|
||||||
|
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
|
||||||
|
say("Found", `${items[id].label} joined your inventory.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function talk(id) {
|
||||||
|
if (id === "archivist") {
|
||||||
|
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
|
||||||
|
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
|
||||||
|
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
|
||||||
|
}
|
||||||
|
if (id === "shade") {
|
||||||
|
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
|
||||||
|
else {
|
||||||
|
state.flags.shadeHelped = true;
|
||||||
|
state.inventory = state.inventory.filter((item) => item !== "page");
|
||||||
|
state.route = "Mercy";
|
||||||
|
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id === "gate") {
|
||||||
|
if (state.flags.shadeHelped) end("mercy");
|
||||||
|
else if (state.inventory.includes("key")) end("power");
|
||||||
|
else end("quiet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function end(kind) {
|
||||||
|
state.ending = kind;
|
||||||
|
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
|
||||||
|
say("Ending", endingLine());
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endingLine() {
|
||||||
|
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
|
||||||
|
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
|
||||||
|
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
|
||||||
|
return state.message.line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(speaker, line) {
|
||||||
|
state.message = { speaker, line };
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
running = true;
|
||||||
|
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
|
||||||
|
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ payload: state })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
||||||
|
els.status.textContent = "Saved to backend.";
|
||||||
|
} catch (_error) {
|
||||||
|
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
state = normalizeState(data.payload);
|
||||||
|
running = true;
|
||||||
|
els.status.textContent = "Loaded from backend.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
/* Fall through to local save. */
|
||||||
|
}
|
||||||
|
const local = localStorage.getItem(fallbackKey);
|
||||||
|
if (local) {
|
||||||
|
state = normalizeState(JSON.parse(local));
|
||||||
|
running = true;
|
||||||
|
els.status.textContent = "Loaded local save.";
|
||||||
|
} else {
|
||||||
|
els.status.textContent = "No save found.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(candidate) {
|
||||||
|
return Object.assign(freshState(), candidate || {}, {
|
||||||
|
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
|
||||||
|
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
|
||||||
|
flags: Object.assign({}, (candidate && candidate.flags) || {}),
|
||||||
|
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function autosave() {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("[data-rpg-start]", root).addEventListener("click", start);
|
||||||
|
$("[data-rpg-save]", root).addEventListener("click", save);
|
||||||
|
$("[data-rpg-load]", root).addEventListener("click", load);
|
||||||
|
$("[data-rpg-reset]", root).addEventListener("click", async () => {
|
||||||
|
state = freshState();
|
||||||
|
running = false;
|
||||||
|
localStorage.removeItem(fallbackKey);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
|
||||||
|
els.status.textContent = "Reset and cleared backend save.";
|
||||||
|
} catch (_error) {
|
||||||
|
els.status.textContent = "Reset locally. Backend save API was not reachable.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("[data-rpg-act]", root).addEventListener("click", act);
|
||||||
|
$$("[data-rpg-move]", root).forEach((button) => {
|
||||||
|
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
|
||||||
|
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (!root.isConnected) return;
|
||||||
|
const tag = event.target.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
|
const keys = {
|
||||||
|
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
|
||||||
|
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
|
||||||
|
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
|
||||||
|
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
|
||||||
|
};
|
||||||
|
if (keys[event.key]) {
|
||||||
|
event.preventDefault();
|
||||||
|
move(...keys[event.key]);
|
||||||
|
}
|
||||||
|
if (event.key === " " || event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
act();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSortable(container, _render, after) {
|
||||||
|
let dragged = null;
|
||||||
|
container.addEventListener("dragstart", (event) => {
|
||||||
|
dragged = event.target.closest("[draggable='true']");
|
||||||
|
if (dragged) event.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
container.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const target = event.target.closest("[draggable='true']");
|
||||||
|
if (!dragged || !target || target === dragged) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2;
|
||||||
|
container.insertBefore(dragged, before ? target : target.nextSibling);
|
||||||
|
after();
|
||||||
|
});
|
||||||
|
container.addEventListener("dragend", () => {
|
||||||
|
dragged = null;
|
||||||
|
after();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(items) {
|
||||||
|
return items[Math.floor(Math.random() * items.length)];
|
||||||
|
}
|
||||||
|
})();
|
||||||
398
assets/scripts/pages/sitemap-interactive.js
Normal file
398
assets/scripts/pages/sitemap-interactive.js
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* sitemap-interactive.js
|
||||||
|
* Drop into /assets/scripts/ — no .org changes required.
|
||||||
|
*
|
||||||
|
* Handles two page structures generated by org-publish:
|
||||||
|
* 1. FLAT LIST — a top-level <ul class="org-ul"> (e.g. Sitemap)
|
||||||
|
* 2. OUTLINE — <div class="outline-2/3"> with <h2>/<h3> headings and
|
||||||
|
* nested <ul class="org-ul"> (e.g. "2025 List", "Blogs List")
|
||||||
|
*
|
||||||
|
* Activated on any page whose <h1 class="title"> matches SITEMAP_PATTERNS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const SITEMAP_PATTERNS = ["sitemap", "list"];
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const titleEl = document.querySelector("h1.title");
|
||||||
|
if (!titleEl) return;
|
||||||
|
const title = titleEl.textContent.trim().toLowerCase();
|
||||||
|
if (!SITEMAP_PATTERNS.some((p) => title.includes(p))) return;
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById("content");
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
|
const hasOutline = !!contentDiv.querySelector(".outline-2, .outline-3");
|
||||||
|
const topUl = !hasOutline && contentDiv.querySelector("ul.org-ul");
|
||||||
|
if (!hasOutline && !topUl) return;
|
||||||
|
|
||||||
|
// ── Parsers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseLi(li) {
|
||||||
|
const link = li.querySelector(":scope > a");
|
||||||
|
const childUl = li.querySelector(":scope > ul");
|
||||||
|
const textNode = Array.from(li.childNodes).find(
|
||||||
|
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim()
|
||||||
|
);
|
||||||
|
const tags = Array.from(li.querySelectorAll(".post-tag")).map((t) =>
|
||||||
|
t.textContent.trim()
|
||||||
|
);
|
||||||
|
const dateEl = li.querySelector(".post-date");
|
||||||
|
const date = dateEl ? dateEl.textContent.trim() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: link
|
||||||
|
? link.textContent.trim()
|
||||||
|
: textNode
|
||||||
|
? textNode.textContent.trim()
|
||||||
|
: li.childNodes[0]?.textContent?.trim() || "",
|
||||||
|
href: link ? link.getAttribute("href") : null,
|
||||||
|
tags,
|
||||||
|
date,
|
||||||
|
children: childUl ? parseUl(childUl) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUl(ul) {
|
||||||
|
return Array.from(ul.children)
|
||||||
|
.filter((li) => li.tagName === "LI")
|
||||||
|
.map(parseLi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlatList() {
|
||||||
|
return { tree: parseUl(topUl), replaceTarget: topUl, wrapOutline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutline() {
|
||||||
|
const tree = [];
|
||||||
|
const outline2s = contentDiv.querySelectorAll(":scope .outline-2");
|
||||||
|
|
||||||
|
if (outline2s.length === 0) {
|
||||||
|
// Only outline-3 directly (flat month grouping without year wrapper)
|
||||||
|
contentDiv.querySelectorAll(":scope .outline-3").forEach((section) => {
|
||||||
|
const heading = section.querySelector("h3");
|
||||||
|
const ul = section.querySelector("ul.org-ul");
|
||||||
|
tree.push({
|
||||||
|
label: heading ? heading.textContent.trim() : "Section",
|
||||||
|
href: null, tags: [], date: null,
|
||||||
|
children: ul ? parseUl(ul) : [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
outline2s.forEach((o2) => {
|
||||||
|
const h2 = o2.querySelector(":scope > div > h2, :scope > h2");
|
||||||
|
const groupNode = {
|
||||||
|
label: h2 ? h2.textContent.trim() : "Group",
|
||||||
|
href: null, tags: [], date: null,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
const outline3s = o2.querySelectorAll(".outline-3");
|
||||||
|
if (outline3s.length > 0) {
|
||||||
|
outline3s.forEach((o3) => {
|
||||||
|
const h3 = o3.querySelector(":scope > div > h3, :scope > h3");
|
||||||
|
const ul = o3.querySelector("ul.org-ul");
|
||||||
|
groupNode.children.push({
|
||||||
|
label: h3 ? h3.textContent.trim() : "Month",
|
||||||
|
href: null, tags: [], date: null,
|
||||||
|
children: ul ? parseUl(ul) : [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const ul = o2.querySelector("ul.org-ul");
|
||||||
|
if (ul) groupNode.children = parseUl(ul);
|
||||||
|
}
|
||||||
|
tree.push(groupNode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tree, replaceTarget: null, wrapOutline: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = hasOutline ? parseOutline() : parseFlatList();
|
||||||
|
const { tree } = parsed;
|
||||||
|
if (!tree.length) return;
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────
|
||||||
|
if (!document.getElementById("sm-styles")) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "sm-styles";
|
||||||
|
style.textContent = `
|
||||||
|
.sitemap-interactive { font-family: inherit; margin: 1.5rem 0; }
|
||||||
|
.sm-toolbar {
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
margin-bottom: 1rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sm-search {
|
||||||
|
flex: 1; min-width: 160px; padding: .35rem .7rem;
|
||||||
|
border: 1px solid var(--border, #444); border-radius: 4px;
|
||||||
|
background: var(--bg, transparent); color: inherit; font-size: .9rem;
|
||||||
|
}
|
||||||
|
.sm-search:focus { outline: 2px solid var(--accent, #7aa2f7); outline-offset: 1px; }
|
||||||
|
.sm-expand-all, .sm-collapse-all {
|
||||||
|
padding: .3rem .65rem; border: 1px solid var(--border, #444);
|
||||||
|
border-radius: 4px; background: transparent; color: inherit;
|
||||||
|
font-size: .8rem; cursor: pointer; opacity: .75; transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.sm-expand-all:hover, .sm-collapse-all:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.sm-tree ul { list-style: none; margin: 0; padding: 0 0 0 1.4rem; }
|
||||||
|
.sm-tree > ul { padding-left: 0; }
|
||||||
|
.sm-tree li { margin: 0; }
|
||||||
|
.sm-node {
|
||||||
|
display: flex; align-items: center; gap: .4rem;
|
||||||
|
padding: .2rem .3rem; border-radius: 4px;
|
||||||
|
line-height: 1.5; transition: background .1s; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sm-node:hover { background: var(--hover-bg, rgba(122,162,247,.08)); }
|
||||||
|
.sm-node.sm-hidden { display: none; }
|
||||||
|
|
||||||
|
.sm-toggle {
|
||||||
|
width: 1.2rem; height: 1.2rem; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; border: none; background: none; color: inherit;
|
||||||
|
font-size: .7rem; opacity: .6;
|
||||||
|
transition: transform .18s, opacity .15s;
|
||||||
|
flex-shrink: 0; padding: 0; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.sm-toggle:hover { opacity: 1; background: var(--hover-bg, rgba(122,162,247,.15)); }
|
||||||
|
.sm-toggle.open { transform: rotate(90deg); }
|
||||||
|
.sm-toggle-placeholder { width: 1.2rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.sm-icon { font-size: .8rem; opacity: .5; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.sm-label a { color: var(--link, inherit); text-decoration: none; font-size: .9rem; }
|
||||||
|
.sm-label a:hover { text-decoration: underline; }
|
||||||
|
.sm-label span { font-size: .9rem; font-weight: 600; opacity: .85; }
|
||||||
|
|
||||||
|
.sm-badge {
|
||||||
|
font-size: .65rem; padding: .05rem .35rem; border-radius: 8px;
|
||||||
|
background: var(--badge-bg, rgba(122,162,247,.15));
|
||||||
|
color: var(--badge-fg, #7aa2f7); opacity: .8;
|
||||||
|
}
|
||||||
|
.sm-date { font-size: .75rem; opacity: .45; margin-left: .2rem; }
|
||||||
|
.sm-tags { display: inline-flex; gap: .25rem; margin-left: .2rem; }
|
||||||
|
.sm-tag {
|
||||||
|
font-size: .65rem; padding: .05rem .3rem; border-radius: 8px;
|
||||||
|
background: var(--tag-bg, rgba(160,200,120,.15));
|
||||||
|
color: var(--tag-fg, #9ece6a); opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-children { overflow: hidden; }
|
||||||
|
.sm-children.collapsed { display: none; }
|
||||||
|
.sm-label mark {
|
||||||
|
background: var(--mark-bg, rgba(255,200,50,.3));
|
||||||
|
color: inherit; border-radius: 2px; padding: 0 1px;
|
||||||
|
}
|
||||||
|
.sm-count { font-size: .78rem; opacity: .45; margin-top: .8rem; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget scaffold ───────────────────────────────────────────────────
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "sitemap-interactive";
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="sm-toolbar">
|
||||||
|
<input class="sm-search" type="search" placeholder="Filter pages…" aria-label="Filter" />
|
||||||
|
<button class="sm-expand-all">⊞ Expand all</button>
|
||||||
|
<button class="sm-collapse-all">⊟ Collapse all</button>
|
||||||
|
</div>
|
||||||
|
<div class="sm-tree" role="tree"></div>
|
||||||
|
<p class="sm-count"></p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const treeEl = wrapper.querySelector(".sm-tree");
|
||||||
|
const searchEl = wrapper.querySelector(".sm-search");
|
||||||
|
const countEl = wrapper.querySelector(".sm-count");
|
||||||
|
|
||||||
|
// ── Tree builder ──────────────────────────────────────────────────────
|
||||||
|
function countLeaves(nodes) {
|
||||||
|
let n = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node.children || !node.children.length) n++;
|
||||||
|
else n += countLeaves(node.children);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(nodes) {
|
||||||
|
const ul = document.createElement("ul");
|
||||||
|
for (const node of nodes) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sm-node";
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
|
let childrenEl;
|
||||||
|
if (hasChildren) {
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.className = "sm-toggle open";
|
||||||
|
toggle.innerHTML = "▶";
|
||||||
|
toggle.setAttribute("aria-expanded", "true");
|
||||||
|
childrenEl = document.createElement("div");
|
||||||
|
childrenEl.className = "sm-children";
|
||||||
|
childrenEl.appendChild(buildTree(node.children));
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
childrenEl.classList.toggle("collapsed");
|
||||||
|
toggle.classList.toggle("open");
|
||||||
|
toggle.setAttribute("aria-expanded",
|
||||||
|
String(!childrenEl.classList.contains("collapsed")));
|
||||||
|
});
|
||||||
|
row.appendChild(toggle);
|
||||||
|
} else {
|
||||||
|
const ph = document.createElement("span");
|
||||||
|
ph.className = "sm-toggle-placeholder";
|
||||||
|
row.appendChild(ph);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "sm-icon";
|
||||||
|
icon.textContent = hasChildren ? "📂" : "📄";
|
||||||
|
row.appendChild(icon);
|
||||||
|
|
||||||
|
const labelEl = document.createElement("span");
|
||||||
|
labelEl.className = "sm-label";
|
||||||
|
if (node.href) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = node.href;
|
||||||
|
a.textContent = node.label;
|
||||||
|
labelEl.appendChild(a);
|
||||||
|
} else {
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.textContent = node.label;
|
||||||
|
labelEl.appendChild(s);
|
||||||
|
}
|
||||||
|
row.appendChild(labelEl);
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "sm-badge";
|
||||||
|
badge.textContent = countLeaves(node.children);
|
||||||
|
row.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.date) {
|
||||||
|
const dateSpan = document.createElement("span");
|
||||||
|
dateSpan.className = "sm-date";
|
||||||
|
dateSpan.textContent = node.date;
|
||||||
|
row.appendChild(dateSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tags && node.tags.length) {
|
||||||
|
const tagsEl = document.createElement("span");
|
||||||
|
tagsEl.className = "sm-tags";
|
||||||
|
node.tags.forEach((t) => {
|
||||||
|
const tag = document.createElement("span");
|
||||||
|
tag.className = "sm-tag";
|
||||||
|
tag.textContent = t;
|
||||||
|
tagsEl.appendChild(tag);
|
||||||
|
});
|
||||||
|
row.appendChild(tagsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(row);
|
||||||
|
if (hasChildren) li.appendChild(childrenEl);
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
return ul;
|
||||||
|
}
|
||||||
|
|
||||||
|
treeEl.appendChild(buildTree(tree));
|
||||||
|
|
||||||
|
// ── Search / filter ───────────────────────────────────────────────────
|
||||||
|
function escapeRe(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTree(q) {
|
||||||
|
const query = q.trim().toLowerCase();
|
||||||
|
const re = query ? new RegExp(escapeRe(query), "gi") : null;
|
||||||
|
let visible = 0;
|
||||||
|
|
||||||
|
function walk(ul) {
|
||||||
|
let anyVisible = false;
|
||||||
|
for (const li of ul.children) {
|
||||||
|
const row = li.querySelector(":scope > .sm-node");
|
||||||
|
const childrenDiv = li.querySelector(":scope > .sm-children");
|
||||||
|
const labelEl = row.querySelector(".sm-label");
|
||||||
|
const target = labelEl.querySelector("a") || labelEl.querySelector("span");
|
||||||
|
const text = target.dataset.orig || target.textContent;
|
||||||
|
target.dataset.orig = text;
|
||||||
|
|
||||||
|
let selfMatch = false;
|
||||||
|
if (!query) {
|
||||||
|
target.innerHTML = "";
|
||||||
|
target.textContent = text;
|
||||||
|
selfMatch = true;
|
||||||
|
} else if (text.toLowerCase().includes(query)) {
|
||||||
|
target.innerHTML = text.replace(re, (m) => `<mark>${m}</mark>`);
|
||||||
|
selfMatch = true;
|
||||||
|
} else {
|
||||||
|
target.innerHTML = "";
|
||||||
|
target.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
let childVisible = false;
|
||||||
|
if (childrenDiv) {
|
||||||
|
childVisible = walk(childrenDiv.querySelector("ul"));
|
||||||
|
if (query) {
|
||||||
|
childrenDiv.classList.toggle("collapsed", !childVisible && !selfMatch);
|
||||||
|
const toggle = row.querySelector(".sm-toggle");
|
||||||
|
if (toggle) toggle.classList.toggle("open", childVisible || selfMatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = !query || selfMatch || childVisible;
|
||||||
|
row.classList.toggle("sm-hidden", !show);
|
||||||
|
if (show) {
|
||||||
|
anyVisible = true;
|
||||||
|
if (!childrenDiv) visible++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anyVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(treeEl.querySelector("ul"));
|
||||||
|
countEl.textContent = query
|
||||||
|
? `${visible} page${visible !== 1 ? "s" : ""} matching "${query}"`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
searchEl.addEventListener("input", (e) => filterTree(e.target.value));
|
||||||
|
|
||||||
|
wrapper.querySelector(".sm-expand-all").addEventListener("click", () => {
|
||||||
|
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.remove("collapsed"));
|
||||||
|
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
||||||
|
el.classList.add("open");
|
||||||
|
el.setAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.querySelector(".sm-collapse-all").addEventListener("click", () => {
|
||||||
|
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.add("collapsed"));
|
||||||
|
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
||||||
|
el.classList.remove("open");
|
||||||
|
el.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inject into page ──────────────────────────────────────────────────
|
||||||
|
if (parsed.wrapOutline) {
|
||||||
|
// Hide original outline divs (direct children of content only)
|
||||||
|
contentDiv.querySelectorAll(":scope > .outline-2, :scope > .outline-3")
|
||||||
|
.forEach((el) => (el.style.display = "none"));
|
||||||
|
// Insert after title + optional intro <p>
|
||||||
|
const insertAfter = contentDiv.querySelector(":scope > p") || titleEl;
|
||||||
|
insertAfter.insertAdjacentElement("afterend", wrapper);
|
||||||
|
} else {
|
||||||
|
parsed.replaceTarget.replaceWith(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1787
assets/scripts/pages/wird-tracker.js
Normal file
1787
assets/scripts/pages/wird-tracker.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,866 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const $ = (sel, root = document) => root.querySelector(sel);
|
"use strict";
|
||||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
var script = document.createElement("script");
|
||||||
const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value);
|
script.src = "/assets/scripts/pages/play.js";
|
||||||
const quips = [
|
script.defer = true;
|
||||||
"A margin note blinks, then pretends it did not.",
|
document.head.appendChild(script);
|
||||||
"Somewhere, a bookmark changes its mind.",
|
}());
|
||||||
"The archive is pleased by unnecessary curiosity.",
|
|
||||||
"A tiny bell rings in a room you have not built yet.",
|
|
||||||
"The page remembers that you came here."
|
|
||||||
];
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const root = $(".play-root[data-play-page]");
|
|
||||||
if (!root) return;
|
|
||||||
const page = root.dataset.playPage;
|
|
||||||
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
|
|
||||||
if (inits[page]) inits[page](root);
|
|
||||||
});
|
|
||||||
|
|
||||||
function hub(root) {
|
|
||||||
const links = $$(".play-grid a", root);
|
|
||||||
const nodes = $$(".play-orbit__node", root);
|
|
||||||
const title = $("#play-hub-title", root);
|
|
||||||
const kind = $("#play-hub-kind", root);
|
|
||||||
const desc = $("#play-hub-desc", root);
|
|
||||||
const cta = $("#play-hub-link", root);
|
|
||||||
const whisper = document.createElement("p");
|
|
||||||
let active = 0;
|
|
||||||
|
|
||||||
whisper.className = "play-whisper";
|
|
||||||
whisper.setAttribute("aria-live", "polite");
|
|
||||||
$(".play-console__screen", root).appendChild(whisper);
|
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
|
||||||
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
|
|
||||||
node.style.left = `${50 + Math.cos(angle) * 43}%`;
|
|
||||||
node.style.top = `${50 + Math.sin(angle) * 43}%`;
|
|
||||||
node.addEventListener("click", () => setActive(index));
|
|
||||||
});
|
|
||||||
|
|
||||||
function setActive(index) {
|
|
||||||
active = (index + links.length) % links.length;
|
|
||||||
const link = links[active];
|
|
||||||
links.forEach((item, i) => item.classList.toggle("is-active", i === active));
|
|
||||||
nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
|
|
||||||
title.textContent = link.querySelector("strong").textContent;
|
|
||||||
kind.textContent = link.dataset.kind;
|
|
||||||
desc.textContent = link.dataset.desc;
|
|
||||||
cta.href = link.href;
|
|
||||||
whisper.textContent = quips[active % quips.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
links.forEach((link, index) => {
|
|
||||||
link.addEventListener("mouseenter", () => setActive(index));
|
|
||||||
link.addEventListener("focus", () => setActive(index));
|
|
||||||
});
|
|
||||||
$("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
|
|
||||||
$("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
|
|
||||||
$("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
|
|
||||||
$(".play-orbit", root).addEventListener("dblclick", () => {
|
|
||||||
whisper.textContent = "You knocked twice. The cabinet knocked once back.";
|
|
||||||
root.classList.toggle("is-odd");
|
|
||||||
});
|
|
||||||
root.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "ArrowLeft") setActive(active - 1);
|
|
||||||
if (event.key === "ArrowRight") setActive(active + 1);
|
|
||||||
});
|
|
||||||
setActive(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function memory(root) {
|
|
||||||
const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
|
|
||||||
const board = $("[data-memory-board]", root);
|
|
||||||
const turnsEl = $("[data-memory-turns]", root);
|
|
||||||
const matchesEl = $("[data-memory-matches]", root);
|
|
||||||
const messageEl = $("[data-memory-message]", root);
|
|
||||||
let open = [];
|
|
||||||
let turns = 0;
|
|
||||||
let matches = 0;
|
|
||||||
let deck = [];
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
board.innerHTML = "";
|
|
||||||
open = [];
|
|
||||||
turns = 0;
|
|
||||||
matches = 0;
|
|
||||||
deck = shuffle([...symbols, ...symbols]);
|
|
||||||
turnsEl.textContent = "0";
|
|
||||||
matchesEl.textContent = "0";
|
|
||||||
messageEl.textContent = "Cards are shuffled.";
|
|
||||||
deck.forEach((symbol, index) => {
|
|
||||||
const card = document.createElement("button");
|
|
||||||
card.type = "button";
|
|
||||||
card.className = "memory-card";
|
|
||||||
card.dataset.symbol = symbol;
|
|
||||||
card.textContent = "?";
|
|
||||||
card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
|
|
||||||
card.setAttribute("aria-label", "Hidden card");
|
|
||||||
card.addEventListener("click", () => flip(card));
|
|
||||||
board.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flip(card) {
|
|
||||||
if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
|
|
||||||
card.classList.add("is-open");
|
|
||||||
card.textContent = card.dataset.symbol;
|
|
||||||
card.setAttribute("aria-label", card.dataset.symbol);
|
|
||||||
open.push(card);
|
|
||||||
if (open.length !== 2) return;
|
|
||||||
turns += 1;
|
|
||||||
turnsEl.textContent = String(turns);
|
|
||||||
const [a, b] = open;
|
|
||||||
if (a.dataset.symbol === b.dataset.symbol) {
|
|
||||||
a.classList.add("is-matched");
|
|
||||||
b.classList.add("is-matched");
|
|
||||||
open = [];
|
|
||||||
matches += 1;
|
|
||||||
matchesEl.textContent = String(matches);
|
|
||||||
messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
|
|
||||||
} else {
|
|
||||||
messageEl.textContent = "No match. The cabinet quietly re-files them.";
|
|
||||||
setTimeout(() => {
|
|
||||||
a.classList.remove("is-open");
|
|
||||||
b.classList.remove("is-open");
|
|
||||||
a.textContent = "?";
|
|
||||||
b.textContent = "?";
|
|
||||||
a.setAttribute("aria-label", "Hidden card");
|
|
||||||
b.setAttribute("aria-label", "Hidden card");
|
|
||||||
open = [];
|
|
||||||
}, 650);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$("[data-memory-reset]", root).addEventListener("click", render);
|
|
||||||
$("[data-memory-peek]", root).addEventListener("click", () => {
|
|
||||||
const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
|
|
||||||
hidden.forEach((card) => {
|
|
||||||
card.textContent = card.dataset.symbol;
|
|
||||||
card.classList.add("is-peeking");
|
|
||||||
});
|
|
||||||
messageEl.textContent = "A very brief lapse in scholarly discipline.";
|
|
||||||
setTimeout(() => {
|
|
||||||
hidden.forEach((card) => {
|
|
||||||
if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
|
|
||||||
card.classList.remove("is-peeking");
|
|
||||||
});
|
|
||||||
}, 900);
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function constellation(root) {
|
|
||||||
const canvas = $("[data-star-canvas]", root);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const linesEl = $("[data-star-lines]", root);
|
|
||||||
const nameEl = document.createElement("strong");
|
|
||||||
const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
|
|
||||||
const lines = [];
|
|
||||||
let selected = null;
|
|
||||||
nameEl.className = "play-live-note";
|
|
||||||
linesEl.closest(".play-scorebar").appendChild(nameEl);
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.fillStyle = "#18130f";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
lines.forEach(([a, b]) => {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(stars[a].x, stars[a].y);
|
|
||||||
ctx.lineTo(stars[b].x, stars[b].y);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
stars.forEach((star, index) => {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
|
|
||||||
ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
linesEl.textContent = String(lines.length);
|
|
||||||
if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("click", (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
|
|
||||||
const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
|
||||||
const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
|
|
||||||
if (hit < 0) return;
|
|
||||||
if (selected === null) selected = hit;
|
|
||||||
else if (selected !== hit) {
|
|
||||||
lines.push([selected, hit]);
|
|
||||||
selected = hit;
|
|
||||||
}
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
$("[data-star-reset]", root).addEventListener("click", () => {
|
|
||||||
lines.length = 0;
|
|
||||||
selected = null;
|
|
||||||
nameEl.textContent = "";
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function poem(root) {
|
|
||||||
const output = $("[data-poem-output]", root);
|
|
||||||
const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
|
|
||||||
const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
|
|
||||||
const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
|
|
||||||
let count = 0;
|
|
||||||
function make() {
|
|
||||||
count += 1;
|
|
||||||
output.innerHTML = `${pick(a)}<br>${pick(b)}<br>${pick(c)}`;
|
|
||||||
output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
|
|
||||||
}
|
|
||||||
$("[data-poem-generate]", root).addEventListener("click", make);
|
|
||||||
make();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bookshelf(root) {
|
|
||||||
const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
|
|
||||||
const shelf = $("[data-bookshelf]", root);
|
|
||||||
const status = $("[data-bookshelf-status]", root);
|
|
||||||
let sortedOnce = false;
|
|
||||||
setupSortable(shelf, render, check);
|
|
||||||
$("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
|
|
||||||
render(shuffle(titles));
|
|
||||||
|
|
||||||
function render(items) {
|
|
||||||
shelf.innerHTML = "";
|
|
||||||
items.forEach((title) => {
|
|
||||||
const book = document.createElement("div");
|
|
||||||
book.className = "book-spine";
|
|
||||||
book.draggable = true;
|
|
||||||
book.dataset.value = title;
|
|
||||||
book.textContent = title;
|
|
||||||
shelf.appendChild(book);
|
|
||||||
});
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
function check() {
|
|
||||||
const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
|
|
||||||
const sorted = current.join("|") === titles.join("|");
|
|
||||||
status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
|
|
||||||
if (sorted && !sortedOnce) {
|
|
||||||
sortedOnce = true;
|
|
||||||
const pamphlet = document.createElement("div");
|
|
||||||
pamphlet.className = "booklet";
|
|
||||||
pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
|
|
||||||
shelf.appendChild(pamphlet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function recipe(root) {
|
|
||||||
const wheel = $("[data-recipe-spin]", root);
|
|
||||||
const result = $("[data-recipe-result]", root);
|
|
||||||
const groups = [
|
|
||||||
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
|
|
||||||
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
|
|
||||||
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
|
|
||||||
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
|
|
||||||
];
|
|
||||||
let spin = 0;
|
|
||||||
let feast = 0;
|
|
||||||
wheel.addEventListener("click", () => {
|
|
||||||
spin += 540 + Math.floor(Math.random() * 540);
|
|
||||||
feast += 1;
|
|
||||||
wheel.style.transform = `rotate(${spin}deg)`;
|
|
||||||
result.innerHTML = groups.map((group) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
|
|
||||||
});
|
|
||||||
wheel.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeline(root) {
|
|
||||||
const items = [
|
|
||||||
{ year: "2022", text: "The personal web habit begins." },
|
|
||||||
{ year: "2025", text: "Org publishing becomes the main site engine." },
|
|
||||||
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
|
|
||||||
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
|
|
||||||
{ year: "2026", text: "The play wing opens." }
|
|
||||||
];
|
|
||||||
const list = $("[data-timeline-list]", root);
|
|
||||||
const status = $("[data-timeline-status]", root);
|
|
||||||
const paradox = document.createElement("button");
|
|
||||||
paradox.type = "button";
|
|
||||||
paradox.textContent = "Paradox";
|
|
||||||
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
|
|
||||||
setupSortable(list, render, check);
|
|
||||||
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
|
|
||||||
paradox.addEventListener("click", () => render([...items].reverse()));
|
|
||||||
render(shuffle(items));
|
|
||||||
|
|
||||||
function render(source) {
|
|
||||||
list.innerHTML = "";
|
|
||||||
source.forEach((item, index) => {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "timeline-card";
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.value = String(items.indexOf(item));
|
|
||||||
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
|
|
||||||
list.appendChild(card);
|
|
||||||
});
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
function check() {
|
|
||||||
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
|
|
||||||
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ink(root) {
|
|
||||||
const canvas = $("[data-ink-canvas]", root);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const drops = [];
|
|
||||||
let moon = false;
|
|
||||||
function addDrop(x, y, heavy) {
|
|
||||||
drops.push({ x, y, r: heavy ? 18 : 8, life: heavy ? 1.8 : 1, hue: 28 + Math.random() * 35 });
|
|
||||||
}
|
|
||||||
function pointer(event, heavy) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
addDrop(((event.clientX - rect.left) / rect.width) * canvas.width, ((event.clientY - rect.top) / rect.height) * canvas.height, heavy);
|
|
||||||
}
|
|
||||||
canvas.addEventListener("pointermove", (event) => pointer(event, false));
|
|
||||||
canvas.addEventListener("click", (event) => pointer(event, true));
|
|
||||||
canvas.addEventListener("dblclick", () => {
|
|
||||||
moon = !moon;
|
|
||||||
});
|
|
||||||
function frame() {
|
|
||||||
ctx.fillStyle = "rgba(24, 19, 15, 0.16)";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
if (moon) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillStyle = "rgba(246, 216, 137, 0.72)";
|
|
||||||
ctx.arc(760, 88, 34, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
drops.forEach((drop) => {
|
|
||||||
drop.r += 0.7;
|
|
||||||
drop.life -= 0.012;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = `hsla(${drop.hue}, 65%, 68%, ${Math.max(drop.life, 0)})`;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.arc(drop.x, drop.y, drop.r, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
for (let i = drops.length - 1; i >= 0; i -= 1) if (drops[i].life <= 0) drops.splice(i, 1);
|
|
||||||
requestAnimationFrame(frame);
|
|
||||||
}
|
|
||||||
frame();
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminal(root) {
|
|
||||||
const log = $("[data-terminal-log]", root);
|
|
||||||
const form = $("[data-terminal-form]", root);
|
|
||||||
const input = $("[data-terminal-input]", root);
|
|
||||||
let kindness = 0;
|
|
||||||
const responses = {
|
|
||||||
help: "Commands: help, look, map, open drawer, read note, brew tea, hum, knock, save, inventory, clear",
|
|
||||||
look: "A narrow archive room. A lamp hums. A drawer is labelled MAYBE IMPORTANT.",
|
|
||||||
map: "You are between the reading desk, the family shelf, and the door back to Play.",
|
|
||||||
"open drawer": "Inside: a brass key, a receipt, and a note folded twice.",
|
|
||||||
"read note": "The note says: keep the site useful, but leave a few doors ajar.",
|
|
||||||
"brew tea": "The room smells briefly of cardamom. Nothing else changes, which is enough.",
|
|
||||||
hum: "You hum four careful notes. Something behind the wall hums five back.",
|
|
||||||
knock: "Knock. Knock. ... A polite pause. Knock.",
|
|
||||||
save: "You save your place in the archive. The archive saves its place in you.",
|
|
||||||
inventory: "You are carrying: a brass key, a warm cup, and one unreasonable hope."
|
|
||||||
};
|
|
||||||
function write(text) {
|
|
||||||
log.textContent += `${text}\n`;
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
form.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const cmd = input.value.trim().toLowerCase();
|
|
||||||
if (!cmd) return;
|
|
||||||
input.value = "";
|
|
||||||
if (cmd === "clear") {
|
|
||||||
log.textContent = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
write(`> ${cmd}`);
|
|
||||||
if (cmd === "pet book") {
|
|
||||||
kindness += 1;
|
|
||||||
write(kindness > 2 ? "The book follows you for exactly three pages." : "The book accepts this with suspicious dignity.");
|
|
||||||
} else {
|
|
||||||
write(responses[cmd] || "The archive declines to understand that command.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
write("Archive terminal ready. Type help. The cursor is listening.");
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function study(root) {
|
|
||||||
const time = $("[data-study-time]", root);
|
|
||||||
const scene = $("[data-study-scene]", root);
|
|
||||||
let total = 300;
|
|
||||||
let left = total;
|
|
||||||
let timer = null;
|
|
||||||
const note = document.createElement("div");
|
|
||||||
note.className = "play-live-note";
|
|
||||||
note.textContent = "The lamp is cold.";
|
|
||||||
time.insertAdjacentElement("afterend", note);
|
|
||||||
function draw() {
|
|
||||||
const done = 1 - left / total;
|
|
||||||
scene.style.setProperty("--lamp", String(0.18 + done * 0.72));
|
|
||||||
scene.style.setProperty("--glow", `${done * 70}px`);
|
|
||||||
const m = String(Math.floor(left / 60)).padStart(2, "0");
|
|
||||||
const s = String(left % 60).padStart(2, "0");
|
|
||||||
time.textContent = `${m}:${s}`;
|
|
||||||
if (left === 0) note.textContent = "Focus complete. The desk looks proud.";
|
|
||||||
else if (done > 0.66) note.textContent = "The page has warmed through.";
|
|
||||||
else if (done > 0.33) note.textContent = "The lamp has settled into its work.";
|
|
||||||
}
|
|
||||||
function start() {
|
|
||||||
if (timer) return;
|
|
||||||
timer = setInterval(() => {
|
|
||||||
left = Math.max(0, left - 1);
|
|
||||||
draw();
|
|
||||||
if (left === 0) pause();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
function pause() {
|
|
||||||
clearInterval(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
$("[data-study-start]", root).addEventListener("click", start);
|
|
||||||
$("[data-study-pause]", root).addEventListener("click", pause);
|
|
||||||
$("[data-study-reset]", root).addEventListener("click", () => {
|
|
||||||
pause();
|
|
||||||
left = total;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sigil(root) {
|
|
||||||
const canvas = $("[data-sigil-canvas]", root);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const initials = $("[data-sigil-initials]", root);
|
|
||||||
const motto = $("[data-sigil-motto]", root);
|
|
||||||
let wax = "#9c6b2f";
|
|
||||||
const waxButton = document.createElement("button");
|
|
||||||
waxButton.type = "button";
|
|
||||||
waxButton.textContent = "New wax";
|
|
||||||
$("[data-sigil-form]", root).appendChild(waxButton);
|
|
||||||
function draw() {
|
|
||||||
const text = (initials.value || "ZXH").toUpperCase();
|
|
||||||
const phrase = motto.value || "Learn, make, remember";
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.fillStyle = "#fbf7ec";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.translate(260, 260);
|
|
||||||
ctx.strokeStyle = wax;
|
|
||||||
ctx.lineWidth = 10;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, 190, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
for (let i = 0; i < 12; i += 1) {
|
|
||||||
ctx.rotate(Math.PI / 6);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, -150);
|
|
||||||
ctx.lineTo(0, -185);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.rotate(-Math.PI * 2);
|
|
||||||
ctx.fillStyle = "#2a2118";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.font = "900 94px Georgia, serif";
|
|
||||||
ctx.fillText(text.slice(0, 4), 0, -10);
|
|
||||||
ctx.font = "700 22px Georgia, serif";
|
|
||||||
ctx.fillText(phrase.slice(0, 34), 0, 88);
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
}
|
|
||||||
waxButton.addEventListener("click", () => {
|
|
||||||
wax = pick(["#9c6b2f", "#8a3f3f", "#486b57", "#3e5f8a", "#5f4b8b"]);
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
$("[data-sigil-form]", root).addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
initials.addEventListener("input", draw);
|
|
||||||
motto.addEventListener("input", draw);
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpg(root) {
|
|
||||||
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
|
|
||||||
const canvas = $("[data-rpg-canvas]", root);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const els = {
|
|
||||||
speaker: $("[data-rpg-speaker]", root),
|
|
||||||
line: $("[data-rpg-line]", root),
|
|
||||||
room: $("[data-rpg-room]", root),
|
|
||||||
route: $("[data-rpg-route]", root),
|
|
||||||
hearts: $("[data-rpg-hearts]", root),
|
|
||||||
quests: $("[data-rpg-quests]", root),
|
|
||||||
inventory: $("[data-rpg-inventory]", root),
|
|
||||||
status: $("[data-rpg-status]", root)
|
|
||||||
};
|
|
||||||
const tile = 32;
|
|
||||||
const saveSlot = "hollow-archive";
|
|
||||||
const fallbackKey = "play:rpg:hollow-archive";
|
|
||||||
const items = {
|
|
||||||
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
|
|
||||||
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
|
|
||||||
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
|
|
||||||
};
|
|
||||||
const npcs = {
|
|
||||||
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
|
|
||||||
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
|
|
||||||
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
|
|
||||||
};
|
|
||||||
const rooms = {
|
|
||||||
entrance: {
|
|
||||||
name: "Entrance",
|
|
||||||
floor: "#353029",
|
|
||||||
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
|
|
||||||
},
|
|
||||||
stacks: {
|
|
||||||
name: "Stacks",
|
|
||||||
floor: "#242d35",
|
|
||||||
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
|
|
||||||
},
|
|
||||||
garden: {
|
|
||||||
name: "Moon Garden",
|
|
||||||
floor: "#21362e",
|
|
||||||
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let state = freshState();
|
|
||||||
let running = false;
|
|
||||||
|
|
||||||
function freshState() {
|
|
||||||
return {
|
|
||||||
room: "entrance",
|
|
||||||
player: { x: 3, y: 7, facing: "down" },
|
|
||||||
inventory: [],
|
|
||||||
flags: {},
|
|
||||||
route: "Undecided",
|
|
||||||
hearts: 3,
|
|
||||||
ending: null,
|
|
||||||
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rects(sources) {
|
|
||||||
const set = new Set();
|
|
||||||
sources.forEach(([x, y, w, h]) => {
|
|
||||||
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
|
|
||||||
});
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const room = rooms[state.room];
|
|
||||||
ctx.fillStyle = "#14110e";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for (let y = 0; y < 16; y += 1) {
|
|
||||||
for (let x = 0; x < 24; x += 1) {
|
|
||||||
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
|
|
||||||
ctx.fillRect(x * tile, y * tile, tile, tile);
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.035)";
|
|
||||||
ctx.strokeRect(x * tile, y * tile, tile, tile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
|
|
||||||
Object.entries(items).forEach(([id, item]) => {
|
|
||||||
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
|
|
||||||
});
|
|
||||||
Object.values(npcs).forEach((npc) => {
|
|
||||||
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
|
|
||||||
});
|
|
||||||
drawPlayer();
|
|
||||||
renderHud();
|
|
||||||
requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGlyph(x, y, color, kind) {
|
|
||||||
const px = x * tile;
|
|
||||||
const py = y * tile;
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
if (kind === "item") {
|
|
||||||
ctx.fillRect(px + 10, py + 10, 12, 12);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
|
||||||
ctx.fillRect(px + 13, py + 7, 6, 6);
|
|
||||||
} else if (kind === "door" || kind === "doorNpc") {
|
|
||||||
ctx.fillRect(px + 7, py + 4, 18, 25);
|
|
||||||
ctx.fillStyle = "#211812";
|
|
||||||
ctx.fillRect(px + 20, py + 16, 3, 3);
|
|
||||||
} else {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.fillRect(px + 8, py + 20, 16, 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPlayer() {
|
|
||||||
const px = state.player.x * tile;
|
|
||||||
const py = state.player.y * tile;
|
|
||||||
ctx.fillStyle = "#f35f5f";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(px + 16, py + 7);
|
|
||||||
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
|
|
||||||
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.fillStyle = "#fff8e8";
|
|
||||||
ctx.fillRect(px + 11, py + 13, 4, 4);
|
|
||||||
ctx.fillRect(px + 18, py + 13, 4, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHud() {
|
|
||||||
els.speaker.textContent = state.message.speaker;
|
|
||||||
els.line.textContent = state.ending ? endingLine() : state.message.line;
|
|
||||||
els.room.textContent = rooms[state.room].name;
|
|
||||||
els.route.textContent = state.route;
|
|
||||||
els.hearts.textContent = String(state.hearts);
|
|
||||||
els.inventory.innerHTML = state.inventory.length
|
|
||||||
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
|
|
||||||
: "<li>Empty</li>";
|
|
||||||
const questRows = [
|
|
||||||
["Find a light", state.inventory.includes("lamp")],
|
|
||||||
["Return the loose page to the shade", state.flags.shadeHelped],
|
|
||||||
["Open, force, or leave the iron door", Boolean(state.ending)]
|
|
||||||
];
|
|
||||||
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(dx, dy, facing) {
|
|
||||||
if (!running || state.ending) return;
|
|
||||||
state.player.facing = facing;
|
|
||||||
const nx = state.player.x + dx;
|
|
||||||
const ny = state.player.y + dy;
|
|
||||||
const room = rooms[state.room];
|
|
||||||
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
|
|
||||||
if (exit) {
|
|
||||||
state.room = exit.to;
|
|
||||||
state.player.x = exit.px;
|
|
||||||
state.player.y = exit.py;
|
|
||||||
say("Narrator", `You enter ${rooms[state.room].name}.`);
|
|
||||||
autosave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
|
|
||||||
state.player.x = nx;
|
|
||||||
state.player.y = ny;
|
|
||||||
const item = itemAt(nx, ny);
|
|
||||||
if (item) take(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function act() {
|
|
||||||
if (!running) return start();
|
|
||||||
if (state.ending) return;
|
|
||||||
const front = inFront();
|
|
||||||
const npc = npcAt(front.x, front.y);
|
|
||||||
if (npc) talk(npc);
|
|
||||||
else {
|
|
||||||
const here = itemAt(state.player.x, state.player.y);
|
|
||||||
if (here) take(here);
|
|
||||||
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
|
|
||||||
}
|
|
||||||
autosave();
|
|
||||||
}
|
|
||||||
|
|
||||||
function inFront() {
|
|
||||||
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
|
|
||||||
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemAt(x, y) {
|
|
||||||
return Object.keys(items).find((id) => {
|
|
||||||
const item = items[id];
|
|
||||||
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function npcAt(x, y) {
|
|
||||||
return Object.keys(npcs).find((id) => {
|
|
||||||
const npc = npcs[id];
|
|
||||||
return npc.room === state.room && npc.x === x && npc.y === y;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function take(id) {
|
|
||||||
state.inventory.push(id);
|
|
||||||
if (id === "lamp") state.route = "Gentle";
|
|
||||||
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
|
|
||||||
say("Found", `${items[id].label} joined your inventory.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function talk(id) {
|
|
||||||
if (id === "archivist") {
|
|
||||||
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
|
|
||||||
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
|
|
||||||
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
|
|
||||||
}
|
|
||||||
if (id === "shade") {
|
|
||||||
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
|
|
||||||
else {
|
|
||||||
state.flags.shadeHelped = true;
|
|
||||||
state.inventory = state.inventory.filter((item) => item !== "page");
|
|
||||||
state.route = "Mercy";
|
|
||||||
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id === "gate") {
|
|
||||||
if (state.flags.shadeHelped) end("mercy");
|
|
||||||
else if (state.inventory.includes("key")) end("power");
|
|
||||||
else end("quiet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function end(kind) {
|
|
||||||
state.ending = kind;
|
|
||||||
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
|
|
||||||
say("Ending", endingLine());
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
|
|
||||||
function endingLine() {
|
|
||||||
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
|
|
||||||
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
|
|
||||||
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
|
|
||||||
return state.message.line;
|
|
||||||
}
|
|
||||||
|
|
||||||
function say(speaker, line) {
|
|
||||||
state.message = { speaker, line };
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
running = true;
|
|
||||||
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
|
|
||||||
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ payload: state })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
|
||||||
els.status.textContent = "Saved to backend.";
|
|
||||||
} catch (_error) {
|
|
||||||
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
state = normalizeState(data.payload);
|
|
||||||
running = true;
|
|
||||||
els.status.textContent = "Loaded from backend.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
/* Fall through to local save. */
|
|
||||||
}
|
|
||||||
const local = localStorage.getItem(fallbackKey);
|
|
||||||
if (local) {
|
|
||||||
state = normalizeState(JSON.parse(local));
|
|
||||||
running = true;
|
|
||||||
els.status.textContent = "Loaded local save.";
|
|
||||||
} else {
|
|
||||||
els.status.textContent = "No save found.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeState(candidate) {
|
|
||||||
return Object.assign(freshState(), candidate || {}, {
|
|
||||||
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
|
|
||||||
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
|
|
||||||
flags: Object.assign({}, (candidate && candidate.flags) || {}),
|
|
||||||
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function autosave() {
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("[data-rpg-start]", root).addEventListener("click", start);
|
|
||||||
$("[data-rpg-save]", root).addEventListener("click", save);
|
|
||||||
$("[data-rpg-load]", root).addEventListener("click", load);
|
|
||||||
$("[data-rpg-reset]", root).addEventListener("click", async () => {
|
|
||||||
state = freshState();
|
|
||||||
running = false;
|
|
||||||
localStorage.removeItem(fallbackKey);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
|
|
||||||
els.status.textContent = "Reset and cleared backend save.";
|
|
||||||
} catch (_error) {
|
|
||||||
els.status.textContent = "Reset locally. Backend save API was not reachable.";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$("[data-rpg-act]", root).addEventListener("click", act);
|
|
||||||
$$("[data-rpg-move]", root).forEach((button) => {
|
|
||||||
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
|
|
||||||
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
|
|
||||||
});
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (!root.isConnected) return;
|
|
||||||
const tag = event.target.tagName;
|
|
||||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
||||||
const keys = {
|
|
||||||
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
|
|
||||||
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
|
|
||||||
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
|
|
||||||
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
|
|
||||||
};
|
|
||||||
if (keys[event.key]) {
|
|
||||||
event.preventDefault();
|
|
||||||
move(...keys[event.key]);
|
|
||||||
}
|
|
||||||
if (event.key === " " || event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
act();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
draw();
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSortable(container, _render, after) {
|
|
||||||
let dragged = null;
|
|
||||||
container.addEventListener("dragstart", (event) => {
|
|
||||||
dragged = event.target.closest("[draggable='true']");
|
|
||||||
if (dragged) event.dataTransfer.effectAllowed = "move";
|
|
||||||
});
|
|
||||||
container.addEventListener("dragover", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const target = event.target.closest("[draggable='true']");
|
|
||||||
if (!dragged || !target || target === dragged) return;
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2;
|
|
||||||
container.insertBefore(dragged, before ? target : target.nextSibling);
|
|
||||||
after();
|
|
||||||
});
|
|
||||||
container.addEventListener("dragend", () => {
|
|
||||||
dragged = null;
|
|
||||||
after();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(items) {
|
|
||||||
return items[Math.floor(Math.random() * items.length)];
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,341 +1,17 @@
|
|||||||
|
|
||||||
|
|
||||||
/* Event listener function for the COPY BUTTON */
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
document.querySelectorAll("pre.src").forEach(function (codeBlock) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.innerText = "Copy";
|
|
||||||
button.className = "copy-btn";
|
|
||||||
|
|
||||||
// Append button inside <pre>
|
|
||||||
codeBlock.appendChild(button);
|
|
||||||
|
|
||||||
button.addEventListener("click", function () {
|
|
||||||
const text = codeBlock.innerText.replace(button.innerText, ""); // exclude button text
|
|
||||||
navigator.clipboard.writeText(text.trim()).then(() => {
|
|
||||||
button.innerText = "Copied!";
|
|
||||||
setTimeout(() => (button.innerText = "Copy"), 1500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/* Event listener for footnotes and sidenotes*/
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
|
|
||||||
const sup = ref.closest("sup") || ref;
|
|
||||||
// idempotent: don't insert twice
|
|
||||||
if (sup.nextElementSibling && sup.nextElementSibling.classList?.contains("footnote-sidenote")) return;
|
|
||||||
|
|
||||||
const targetId = ref.getAttribute("href").replace(/^#/, ""); // works for fn.2 or fn2
|
|
||||||
|
|
||||||
const anchor = document.getElementById(targetId);
|
|
||||||
if (!anchor) return;
|
|
||||||
|
|
||||||
const footdef = anchor.closest(".footdef") || anchor.parentElement;
|
|
||||||
if (!footdef) return;
|
|
||||||
|
|
||||||
// 1) Prefer leaf paragraphs to avoid div+p duplication
|
|
||||||
let paras = footdef.querySelectorAll("p.footpara");
|
|
||||||
if (!paras.length) {
|
|
||||||
// fallback: any .footpara elements that don't contain another .footpara
|
|
||||||
paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Build HTML, de-duplicating by text content
|
|
||||||
let parts = [];
|
|
||||||
if (paras.length) {
|
|
||||||
const seen = new Set();
|
|
||||||
parts = Array.from(paras).map(p => {
|
|
||||||
const txt = p.textContent.trim().replace(/\s+/g, " ");
|
|
||||||
if (seen.has(txt)) return "";
|
|
||||||
seen.add(txt);
|
|
||||||
return p.innerHTML.trim();
|
|
||||||
}).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Fallback: clean full block if no paras found
|
|
||||||
if (!parts.length) {
|
|
||||||
const clone = footdef.cloneNode(true);
|
|
||||||
clone.querySelectorAll("sup.footnum, a[role='doc-backlink']").forEach(n => n.remove());
|
|
||||||
parts = [clone.innerHTML.trim()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Insert the sidenote
|
|
||||||
const sn = document.createElement("span");
|
|
||||||
sn.className = "sidenote footnote-sidenote";
|
|
||||||
sn.setAttribute("data-fn", (ref.textContent || "").trim());
|
|
||||||
sn.innerHTML = parts.join(" ");
|
|
||||||
|
|
||||||
sup.insertAdjacentElement("afterend", sn);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/* Function for setting the theme */
|
|
||||||
(function(){
|
|
||||||
const root = document.documentElement;
|
|
||||||
const storageKey = "theme";
|
|
||||||
const themes = ["light", "dark", "dark-academia"];
|
|
||||||
const labels = {
|
|
||||||
"light": "Light",
|
|
||||||
"dark": "Dark",
|
|
||||||
"dark-academia": "Academia"
|
|
||||||
};
|
|
||||||
const saved = localStorage.getItem(storageKey);
|
|
||||||
if (themes.includes(saved)) {
|
|
||||||
root.setAttribute("data-theme", saved);
|
|
||||||
}
|
|
||||||
const btn = document.getElementById("theme-toggle");
|
|
||||||
if (!btn) return;
|
|
||||||
const updateButton = () => {
|
|
||||||
const current = root.getAttribute("data-theme");
|
|
||||||
const label = labels[current] || "Auto";
|
|
||||||
btn.textContent = `Theme: ${label}`;
|
|
||||||
btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`);
|
|
||||||
};
|
|
||||||
updateButton();
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
const current = root.getAttribute("data-theme");
|
|
||||||
const index = themes.indexOf(current);
|
|
||||||
const target = themes[index === -1 ? 1 : (index + 1) % themes.length];
|
|
||||||
root.setAttribute("data-theme", target);
|
|
||||||
localStorage.setItem(storageKey, target);
|
|
||||||
updateButton();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// Simple, timezone-safe if you pass UTC (…Z) in the datetime
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const els = document.querySelectorAll('time.countdown');
|
|
||||||
if (!els.length) return;
|
|
||||||
|
|
||||||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
const render = (el) => {
|
|
||||||
const raw = el.getAttribute('datetime');
|
|
||||||
const label = el.dataset.label || '';
|
|
||||||
const target = new Date(raw); // Prefer ISO like 2025-12-31T00:00:00Z
|
|
||||||
if (isNaN(target)) { el.textContent = '—'; return; }
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
let diff = target - now;
|
|
||||||
|
|
||||||
if (diff <= 0) {
|
|
||||||
el.textContent = `${label ? label + ' ' : ''}today`;
|
|
||||||
el.classList.add('expired');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const d = Math.floor(diff / 86400000); diff -= d * 86400000;
|
|
||||||
const h = Math.floor(diff / 3600000); diff -= h * 3600000;
|
|
||||||
const m = Math.floor(diff / 60000); diff -= m * 60000;
|
|
||||||
const s = Math.floor(diff / 1000);
|
|
||||||
|
|
||||||
const pieces = [];
|
|
||||||
if (d) pieces.push(plural(d, 'day'));
|
|
||||||
pieces.push(`${h}h ${m}m ${s}s`);
|
|
||||||
|
|
||||||
el.textContent = `${label ? label + ' in : ' : ''}${pieces.join(' ')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = () => els.forEach(render);
|
|
||||||
tick();
|
|
||||||
setInterval(tick, 1000); // update every second
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Event listener for scrolling and changing the active label on the TOC */
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const toc = document.querySelector("#text-table-of-contents");
|
|
||||||
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
|
|
||||||
|
|
||||||
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
|
||||||
// <a href="#introduction">Intro</a> matches
|
|
||||||
if (!links.length) { console.warn("No ToC links found"); return; }
|
|
||||||
|
|
||||||
// Map: id -> link
|
|
||||||
const linkById = new Map();
|
|
||||||
links.forEach(a => {
|
|
||||||
const id = decodeURIComponent(a.getAttribute("href").slice(1));
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) linkById.set(id, a);
|
|
||||||
});
|
|
||||||
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
|
|
||||||
|
|
||||||
// Headings to observe (h2–h4 usually)
|
|
||||||
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
|
||||||
.filter(h => linkById.has(h.id));
|
|
||||||
|
|
||||||
// Helper to mark active
|
|
||||||
const setActive = (id) => {
|
|
||||||
links.forEach(a => {
|
|
||||||
const active = a.getAttribute("href") === `#${id}`;
|
|
||||||
a.classList.toggle("is-active", active);
|
|
||||||
if (active) a.setAttribute("aria-current", "true");
|
|
||||||
else a.removeAttribute("aria-current");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate sticky header offset in px
|
|
||||||
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
||||||
|
|
||||||
// Track visible headings (id -> distance from top)
|
|
||||||
const visible = new Map();
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const id = entry.target.id;
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
// How far from the top (after header offset)
|
|
||||||
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
|
|
||||||
visible.set(id, dist);
|
|
||||||
} else {
|
|
||||||
visible.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (visible.size) {
|
|
||||||
// Choose the heading closest to the top (>= -headerOffset)
|
|
||||||
const topMost = [...visible.entries()]
|
|
||||||
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
|
|
||||||
setActive(topMost);
|
|
||||||
// console.log("Active:", topMost, visible);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
root: null, // track relative to viewport
|
|
||||||
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
|
|
||||||
threshold: [0, 0.01, 0.1] // fire as soon as it enters
|
|
||||||
});
|
|
||||||
|
|
||||||
headings.forEach(h => observer.observe(h));
|
|
||||||
|
|
||||||
// Initial highlight (in case load mid‑page)
|
|
||||||
let bestId = null, bestDist = Infinity;
|
|
||||||
headings.forEach(h => {
|
|
||||||
const top = h.getBoundingClientRect().top - headerOffsetPx;
|
|
||||||
const dist = top < 0 ? Math.abs(top) : top + 1e6;
|
|
||||||
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
|
|
||||||
});
|
|
||||||
if (bestId) setActive(bestId);
|
|
||||||
|
|
||||||
// smooth-scroll ToC clicks
|
|
||||||
toc.addEventListener("click", (e) => {
|
|
||||||
const a = e.target.closest('a[href^="#"]');
|
|
||||||
if (!a) return;
|
|
||||||
const id = decodeURIComponent(a.hash.slice(1));
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
e.preventDefault();
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
el.setAttribute("tabindex", "-1");
|
|
||||||
el.focus({ preventScroll: true });
|
|
||||||
history.pushState(null, "", `#${id}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Make Mermaid diagrams use the active site theme and zoom controls.
|
|
||||||
(function () {
|
(function () {
|
||||||
function cssVar(name, fallback) {
|
"use strict";
|
||||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
||||||
return value || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mermaidConfig() {
|
[
|
||||||
return {
|
"/assets/scripts/ui/copy-buttons.js",
|
||||||
startOnLoad: false,
|
"/assets/scripts/ui/footnote-sidenotes.js",
|
||||||
theme: "base",
|
"/assets/scripts/application/theme-switcher.js",
|
||||||
themeVariables: {
|
"/assets/scripts/ui/countdown.js",
|
||||||
background: cssVar("--bg", "#faf9f6"),
|
"/assets/scripts/ui/toc-active-link.js",
|
||||||
mainBkg: cssVar("--surface", "#ffffff"),
|
"/assets/scripts/ui/mermaid-diagrams.js"
|
||||||
primaryColor: cssVar("--surface", "#ffffff"),
|
].forEach(function (src) {
|
||||||
primaryTextColor: cssVar("--fg", "#000000"),
|
var script = document.createElement("script");
|
||||||
primaryBorderColor: cssVar("--accent", "#2563eb"),
|
script.src = src;
|
||||||
secondaryColor: cssVar("--surface-soft", "#f3f1eb"),
|
script.defer = true;
|
||||||
tertiaryColor: cssVar("--bg", "#faf9f6"),
|
document.head.appendChild(script);
|
||||||
clusterBkg: cssVar("--surface-soft", "#f3f1eb"),
|
});
|
||||||
clusterBorder: cssVar("--border", "#d7d7d7"),
|
}());
|
||||||
lineColor: cssVar("--border", "#d7d7d7"),
|
|
||||||
textColor: cssVar("--fg", "#000000"),
|
|
||||||
edgeLabelBackground: cssVar("--surface", "#ffffff"),
|
|
||||||
fontFamily: cssVar("--font-body", "Noto, system-ui, sans-serif")
|
|
||||||
},
|
|
||||||
flowchart: {
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: "basis"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeMermaidSource(source) {
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.innerHTML = source;
|
|
||||||
return textarea.value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderMermaid() {
|
|
||||||
if (!window.mermaid) return;
|
|
||||||
const diagrams = document.querySelectorAll(".mermaid");
|
|
||||||
if (!diagrams.length) return;
|
|
||||||
|
|
||||||
diagrams.forEach((el) => {
|
|
||||||
if (!el.dataset.source) {
|
|
||||||
el.dataset.source = decodeMermaidSource(el.textContent || el.innerHTML);
|
|
||||||
}
|
|
||||||
el.removeAttribute("data-processed");
|
|
||||||
el.innerHTML = el.dataset.source;
|
|
||||||
});
|
|
||||||
|
|
||||||
mermaid.initialize(mermaidConfig());
|
|
||||||
await mermaid.run({ querySelector: ".mermaid" });
|
|
||||||
wrapMermaidDiagrams();
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapMermaidDiagrams() {
|
|
||||||
document.querySelectorAll(".mermaid").forEach((el) => {
|
|
||||||
if (el.closest(".mermaid-container")) return;
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.className = "mermaid-container";
|
|
||||||
if ((el.dataset.source || "").includes('root(["zxh"])')) {
|
|
||||||
container.classList.add("mermaid-container--site-map");
|
|
||||||
}
|
|
||||||
const controls = document.createElement("div");
|
|
||||||
controls.className = "mermaid-zoom-controls";
|
|
||||||
controls.innerHTML = `
|
|
||||||
<button class="zoom-in" type="button" aria-label="Zoom in">+</button>
|
|
||||||
<button class="zoom-out" type="button" aria-label="Zoom out">-</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
el.replaceWith(container);
|
|
||||||
container.appendChild(controls);
|
|
||||||
container.appendChild(el);
|
|
||||||
|
|
||||||
let scale = 1;
|
|
||||||
const setScale = (next) => {
|
|
||||||
const svg = container.querySelector(".mermaid svg");
|
|
||||||
if (!svg) return;
|
|
||||||
scale = Math.max(0.2, Math.min(3, next));
|
|
||||||
svg.style.transform = `scale(${scale})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
controls.querySelector(".zoom-in").addEventListener("click", () => setScale(scale + 0.1));
|
|
||||||
controls.querySelector(".zoom-out").addEventListener("click", () => setScale(scale - 0.1));
|
|
||||||
container.addEventListener("wheel", (e) => {
|
|
||||||
if (!e.ctrlKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setScale(scale + (e.deltaY < 0 ? 0.05 : -0.05));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", renderMermaid);
|
|
||||||
} else {
|
|
||||||
renderMermaid();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function buildIndex() {
|
|||||||
|
|
||||||
async function initSearch() {
|
async function initSearch() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/test.json");
|
const response = await fetch("/search-index.json");
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|
||||||
extractDocuments(json);
|
extractDocuments(json);
|
||||||
|
|||||||
@@ -1,398 +1,7 @@
|
|||||||
/**
|
|
||||||
* sitemap-interactive.js
|
|
||||||
* Drop into /assets/scripts/ — no .org changes required.
|
|
||||||
*
|
|
||||||
* Handles two page structures generated by org-publish:
|
|
||||||
* 1. FLAT LIST — a top-level <ul class="org-ul"> (e.g. Sitemap)
|
|
||||||
* 2. OUTLINE — <div class="outline-2/3"> with <h2>/<h3> headings and
|
|
||||||
* nested <ul class="org-ul"> (e.g. "2025 List", "Blogs List")
|
|
||||||
*
|
|
||||||
* Activated on any page whose <h1 class="title"> matches SITEMAP_PATTERNS.
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var script = document.createElement("script");
|
||||||
const SITEMAP_PATTERNS = ["sitemap", "list"];
|
script.src = "/assets/scripts/pages/sitemap-interactive.js";
|
||||||
|
script.defer = true;
|
||||||
function init() {
|
document.head.appendChild(script);
|
||||||
const titleEl = document.querySelector("h1.title");
|
}());
|
||||||
if (!titleEl) return;
|
|
||||||
const title = titleEl.textContent.trim().toLowerCase();
|
|
||||||
if (!SITEMAP_PATTERNS.some((p) => title.includes(p))) return;
|
|
||||||
|
|
||||||
const contentDiv = document.getElementById("content");
|
|
||||||
if (!contentDiv) return;
|
|
||||||
|
|
||||||
const hasOutline = !!contentDiv.querySelector(".outline-2, .outline-3");
|
|
||||||
const topUl = !hasOutline && contentDiv.querySelector("ul.org-ul");
|
|
||||||
if (!hasOutline && !topUl) return;
|
|
||||||
|
|
||||||
// ── Parsers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function parseLi(li) {
|
|
||||||
const link = li.querySelector(":scope > a");
|
|
||||||
const childUl = li.querySelector(":scope > ul");
|
|
||||||
const textNode = Array.from(li.childNodes).find(
|
|
||||||
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim()
|
|
||||||
);
|
|
||||||
const tags = Array.from(li.querySelectorAll(".post-tag")).map((t) =>
|
|
||||||
t.textContent.trim()
|
|
||||||
);
|
|
||||||
const dateEl = li.querySelector(".post-date");
|
|
||||||
const date = dateEl ? dateEl.textContent.trim() : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: link
|
|
||||||
? link.textContent.trim()
|
|
||||||
: textNode
|
|
||||||
? textNode.textContent.trim()
|
|
||||||
: li.childNodes[0]?.textContent?.trim() || "",
|
|
||||||
href: link ? link.getAttribute("href") : null,
|
|
||||||
tags,
|
|
||||||
date,
|
|
||||||
children: childUl ? parseUl(childUl) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUl(ul) {
|
|
||||||
return Array.from(ul.children)
|
|
||||||
.filter((li) => li.tagName === "LI")
|
|
||||||
.map(parseLi);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFlatList() {
|
|
||||||
return { tree: parseUl(topUl), replaceTarget: topUl, wrapOutline: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOutline() {
|
|
||||||
const tree = [];
|
|
||||||
const outline2s = contentDiv.querySelectorAll(":scope .outline-2");
|
|
||||||
|
|
||||||
if (outline2s.length === 0) {
|
|
||||||
// Only outline-3 directly (flat month grouping without year wrapper)
|
|
||||||
contentDiv.querySelectorAll(":scope .outline-3").forEach((section) => {
|
|
||||||
const heading = section.querySelector("h3");
|
|
||||||
const ul = section.querySelector("ul.org-ul");
|
|
||||||
tree.push({
|
|
||||||
label: heading ? heading.textContent.trim() : "Section",
|
|
||||||
href: null, tags: [], date: null,
|
|
||||||
children: ul ? parseUl(ul) : [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
outline2s.forEach((o2) => {
|
|
||||||
const h2 = o2.querySelector(":scope > div > h2, :scope > h2");
|
|
||||||
const groupNode = {
|
|
||||||
label: h2 ? h2.textContent.trim() : "Group",
|
|
||||||
href: null, tags: [], date: null,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
const outline3s = o2.querySelectorAll(".outline-3");
|
|
||||||
if (outline3s.length > 0) {
|
|
||||||
outline3s.forEach((o3) => {
|
|
||||||
const h3 = o3.querySelector(":scope > div > h3, :scope > h3");
|
|
||||||
const ul = o3.querySelector("ul.org-ul");
|
|
||||||
groupNode.children.push({
|
|
||||||
label: h3 ? h3.textContent.trim() : "Month",
|
|
||||||
href: null, tags: [], date: null,
|
|
||||||
children: ul ? parseUl(ul) : [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const ul = o2.querySelector("ul.org-ul");
|
|
||||||
if (ul) groupNode.children = parseUl(ul);
|
|
||||||
}
|
|
||||||
tree.push(groupNode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tree, replaceTarget: null, wrapOutline: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = hasOutline ? parseOutline() : parseFlatList();
|
|
||||||
const { tree } = parsed;
|
|
||||||
if (!tree.length) return;
|
|
||||||
|
|
||||||
// ── Styles ────────────────────────────────────────────────────────────
|
|
||||||
if (!document.getElementById("sm-styles")) {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = "sm-styles";
|
|
||||||
style.textContent = `
|
|
||||||
.sitemap-interactive { font-family: inherit; margin: 1.5rem 0; }
|
|
||||||
.sm-toolbar {
|
|
||||||
display: flex; align-items: center; gap: .6rem;
|
|
||||||
margin-bottom: 1rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.sm-search {
|
|
||||||
flex: 1; min-width: 160px; padding: .35rem .7rem;
|
|
||||||
border: 1px solid var(--border, #444); border-radius: 4px;
|
|
||||||
background: var(--bg, transparent); color: inherit; font-size: .9rem;
|
|
||||||
}
|
|
||||||
.sm-search:focus { outline: 2px solid var(--accent, #7aa2f7); outline-offset: 1px; }
|
|
||||||
.sm-expand-all, .sm-collapse-all {
|
|
||||||
padding: .3rem .65rem; border: 1px solid var(--border, #444);
|
|
||||||
border-radius: 4px; background: transparent; color: inherit;
|
|
||||||
font-size: .8rem; cursor: pointer; opacity: .75; transition: opacity .15s;
|
|
||||||
}
|
|
||||||
.sm-expand-all:hover, .sm-collapse-all:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.sm-tree ul { list-style: none; margin: 0; padding: 0 0 0 1.4rem; }
|
|
||||||
.sm-tree > ul { padding-left: 0; }
|
|
||||||
.sm-tree li { margin: 0; }
|
|
||||||
.sm-node {
|
|
||||||
display: flex; align-items: center; gap: .4rem;
|
|
||||||
padding: .2rem .3rem; border-radius: 4px;
|
|
||||||
line-height: 1.5; transition: background .1s; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.sm-node:hover { background: var(--hover-bg, rgba(122,162,247,.08)); }
|
|
||||||
.sm-node.sm-hidden { display: none; }
|
|
||||||
|
|
||||||
.sm-toggle {
|
|
||||||
width: 1.2rem; height: 1.2rem; display: inline-flex;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
cursor: pointer; border: none; background: none; color: inherit;
|
|
||||||
font-size: .7rem; opacity: .6;
|
|
||||||
transition: transform .18s, opacity .15s;
|
|
||||||
flex-shrink: 0; padding: 0; border-radius: 3px;
|
|
||||||
}
|
|
||||||
.sm-toggle:hover { opacity: 1; background: var(--hover-bg, rgba(122,162,247,.15)); }
|
|
||||||
.sm-toggle.open { transform: rotate(90deg); }
|
|
||||||
.sm-toggle-placeholder { width: 1.2rem; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.sm-icon { font-size: .8rem; opacity: .5; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.sm-label a { color: var(--link, inherit); text-decoration: none; font-size: .9rem; }
|
|
||||||
.sm-label a:hover { text-decoration: underline; }
|
|
||||||
.sm-label span { font-size: .9rem; font-weight: 600; opacity: .85; }
|
|
||||||
|
|
||||||
.sm-badge {
|
|
||||||
font-size: .65rem; padding: .05rem .35rem; border-radius: 8px;
|
|
||||||
background: var(--badge-bg, rgba(122,162,247,.15));
|
|
||||||
color: var(--badge-fg, #7aa2f7); opacity: .8;
|
|
||||||
}
|
|
||||||
.sm-date { font-size: .75rem; opacity: .45; margin-left: .2rem; }
|
|
||||||
.sm-tags { display: inline-flex; gap: .25rem; margin-left: .2rem; }
|
|
||||||
.sm-tag {
|
|
||||||
font-size: .65rem; padding: .05rem .3rem; border-radius: 8px;
|
|
||||||
background: var(--tag-bg, rgba(160,200,120,.15));
|
|
||||||
color: var(--tag-fg, #9ece6a); opacity: .85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sm-children { overflow: hidden; }
|
|
||||||
.sm-children.collapsed { display: none; }
|
|
||||||
.sm-label mark {
|
|
||||||
background: var(--mark-bg, rgba(255,200,50,.3));
|
|
||||||
color: inherit; border-radius: 2px; padding: 0 1px;
|
|
||||||
}
|
|
||||||
.sm-count { font-size: .78rem; opacity: .45; margin-top: .8rem; }
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Widget scaffold ───────────────────────────────────────────────────
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "sitemap-interactive";
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="sm-toolbar">
|
|
||||||
<input class="sm-search" type="search" placeholder="Filter pages…" aria-label="Filter" />
|
|
||||||
<button class="sm-expand-all">⊞ Expand all</button>
|
|
||||||
<button class="sm-collapse-all">⊟ Collapse all</button>
|
|
||||||
</div>
|
|
||||||
<div class="sm-tree" role="tree"></div>
|
|
||||||
<p class="sm-count"></p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const treeEl = wrapper.querySelector(".sm-tree");
|
|
||||||
const searchEl = wrapper.querySelector(".sm-search");
|
|
||||||
const countEl = wrapper.querySelector(".sm-count");
|
|
||||||
|
|
||||||
// ── Tree builder ──────────────────────────────────────────────────────
|
|
||||||
function countLeaves(nodes) {
|
|
||||||
let n = 0;
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!node.children || !node.children.length) n++;
|
|
||||||
else n += countLeaves(node.children);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTree(nodes) {
|
|
||||||
const ul = document.createElement("ul");
|
|
||||||
for (const node of nodes) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sm-node";
|
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
|
||||||
|
|
||||||
let childrenEl;
|
|
||||||
if (hasChildren) {
|
|
||||||
const toggle = document.createElement("button");
|
|
||||||
toggle.className = "sm-toggle open";
|
|
||||||
toggle.innerHTML = "▶";
|
|
||||||
toggle.setAttribute("aria-expanded", "true");
|
|
||||||
childrenEl = document.createElement("div");
|
|
||||||
childrenEl.className = "sm-children";
|
|
||||||
childrenEl.appendChild(buildTree(node.children));
|
|
||||||
toggle.addEventListener("click", () => {
|
|
||||||
childrenEl.classList.toggle("collapsed");
|
|
||||||
toggle.classList.toggle("open");
|
|
||||||
toggle.setAttribute("aria-expanded",
|
|
||||||
String(!childrenEl.classList.contains("collapsed")));
|
|
||||||
});
|
|
||||||
row.appendChild(toggle);
|
|
||||||
} else {
|
|
||||||
const ph = document.createElement("span");
|
|
||||||
ph.className = "sm-toggle-placeholder";
|
|
||||||
row.appendChild(ph);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "sm-icon";
|
|
||||||
icon.textContent = hasChildren ? "📂" : "📄";
|
|
||||||
row.appendChild(icon);
|
|
||||||
|
|
||||||
const labelEl = document.createElement("span");
|
|
||||||
labelEl.className = "sm-label";
|
|
||||||
if (node.href) {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = node.href;
|
|
||||||
a.textContent = node.label;
|
|
||||||
labelEl.appendChild(a);
|
|
||||||
} else {
|
|
||||||
const s = document.createElement("span");
|
|
||||||
s.textContent = node.label;
|
|
||||||
labelEl.appendChild(s);
|
|
||||||
}
|
|
||||||
row.appendChild(labelEl);
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
const badge = document.createElement("span");
|
|
||||||
badge.className = "sm-badge";
|
|
||||||
badge.textContent = countLeaves(node.children);
|
|
||||||
row.appendChild(badge);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.date) {
|
|
||||||
const dateSpan = document.createElement("span");
|
|
||||||
dateSpan.className = "sm-date";
|
|
||||||
dateSpan.textContent = node.date;
|
|
||||||
row.appendChild(dateSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.tags && node.tags.length) {
|
|
||||||
const tagsEl = document.createElement("span");
|
|
||||||
tagsEl.className = "sm-tags";
|
|
||||||
node.tags.forEach((t) => {
|
|
||||||
const tag = document.createElement("span");
|
|
||||||
tag.className = "sm-tag";
|
|
||||||
tag.textContent = t;
|
|
||||||
tagsEl.appendChild(tag);
|
|
||||||
});
|
|
||||||
row.appendChild(tagsEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.appendChild(row);
|
|
||||||
if (hasChildren) li.appendChild(childrenEl);
|
|
||||||
ul.appendChild(li);
|
|
||||||
}
|
|
||||||
return ul;
|
|
||||||
}
|
|
||||||
|
|
||||||
treeEl.appendChild(buildTree(tree));
|
|
||||||
|
|
||||||
// ── Search / filter ───────────────────────────────────────────────────
|
|
||||||
function escapeRe(s) {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterTree(q) {
|
|
||||||
const query = q.trim().toLowerCase();
|
|
||||||
const re = query ? new RegExp(escapeRe(query), "gi") : null;
|
|
||||||
let visible = 0;
|
|
||||||
|
|
||||||
function walk(ul) {
|
|
||||||
let anyVisible = false;
|
|
||||||
for (const li of ul.children) {
|
|
||||||
const row = li.querySelector(":scope > .sm-node");
|
|
||||||
const childrenDiv = li.querySelector(":scope > .sm-children");
|
|
||||||
const labelEl = row.querySelector(".sm-label");
|
|
||||||
const target = labelEl.querySelector("a") || labelEl.querySelector("span");
|
|
||||||
const text = target.dataset.orig || target.textContent;
|
|
||||||
target.dataset.orig = text;
|
|
||||||
|
|
||||||
let selfMatch = false;
|
|
||||||
if (!query) {
|
|
||||||
target.innerHTML = "";
|
|
||||||
target.textContent = text;
|
|
||||||
selfMatch = true;
|
|
||||||
} else if (text.toLowerCase().includes(query)) {
|
|
||||||
target.innerHTML = text.replace(re, (m) => `<mark>${m}</mark>`);
|
|
||||||
selfMatch = true;
|
|
||||||
} else {
|
|
||||||
target.innerHTML = "";
|
|
||||||
target.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
let childVisible = false;
|
|
||||||
if (childrenDiv) {
|
|
||||||
childVisible = walk(childrenDiv.querySelector("ul"));
|
|
||||||
if (query) {
|
|
||||||
childrenDiv.classList.toggle("collapsed", !childVisible && !selfMatch);
|
|
||||||
const toggle = row.querySelector(".sm-toggle");
|
|
||||||
if (toggle) toggle.classList.toggle("open", childVisible || selfMatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = !query || selfMatch || childVisible;
|
|
||||||
row.classList.toggle("sm-hidden", !show);
|
|
||||||
if (show) {
|
|
||||||
anyVisible = true;
|
|
||||||
if (!childrenDiv) visible++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return anyVisible;
|
|
||||||
}
|
|
||||||
|
|
||||||
walk(treeEl.querySelector("ul"));
|
|
||||||
countEl.textContent = query
|
|
||||||
? `${visible} page${visible !== 1 ? "s" : ""} matching "${query}"`
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
searchEl.addEventListener("input", (e) => filterTree(e.target.value));
|
|
||||||
|
|
||||||
wrapper.querySelector(".sm-expand-all").addEventListener("click", () => {
|
|
||||||
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.remove("collapsed"));
|
|
||||||
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
|
||||||
el.classList.add("open");
|
|
||||||
el.setAttribute("aria-expanded", "true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
wrapper.querySelector(".sm-collapse-all").addEventListener("click", () => {
|
|
||||||
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.add("collapsed"));
|
|
||||||
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
|
||||||
el.classList.remove("open");
|
|
||||||
el.setAttribute("aria-expanded", "false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Inject into page ──────────────────────────────────────────────────
|
|
||||||
if (parsed.wrapOutline) {
|
|
||||||
// Hide original outline divs (direct children of content only)
|
|
||||||
contentDiv.querySelectorAll(":scope > .outline-2, :scope > .outline-3")
|
|
||||||
.forEach((el) => (el.style.display = "none"));
|
|
||||||
// Insert after title + optional intro <p>
|
|
||||||
const insertAfter = contentDiv.querySelector(":scope > p") || titleEl;
|
|
||||||
insertAfter.insertAdjacentElement("afterend", wrapper);
|
|
||||||
} else {
|
|
||||||
parsed.replaceTarget.replaceWith(wrapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
34
assets/scripts/svg-pan-zoom.min.js
vendored
34
assets/scripts/svg-pan-zoom.min.js
vendored
File diff suppressed because one or more lines are too long
19
assets/scripts/ui/copy-buttons.js
Normal file
19
assets/scripts/ui/copy-buttons.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/* Event listener function for the COPY BUTTON */
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
document.querySelectorAll("pre.src").forEach(function (codeBlock) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.innerText = "Copy";
|
||||||
|
button.className = "copy-btn";
|
||||||
|
|
||||||
|
// Append button inside <pre>
|
||||||
|
codeBlock.appendChild(button);
|
||||||
|
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
const text = codeBlock.innerText.replace(button.innerText, ""); // exclude button text
|
||||||
|
navigator.clipboard.writeText(text.trim()).then(() => {
|
||||||
|
button.innerText = "Copied!";
|
||||||
|
setTimeout(() => (button.innerText = "Copy"), 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
assets/scripts/ui/countdown.js
Normal file
38
assets/scripts/ui/countdown.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Simple, timezone-safe if you pass UTC (…Z) in the datetime
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const els = document.querySelectorAll('time.countdown');
|
||||||
|
if (!els.length) return;
|
||||||
|
|
||||||
|
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
const render = (el) => {
|
||||||
|
const raw = el.getAttribute('datetime');
|
||||||
|
const label = el.dataset.label || '';
|
||||||
|
const target = new Date(raw); // Prefer ISO like 2025-12-31T00:00:00Z
|
||||||
|
if (isNaN(target)) { el.textContent = '—'; return; }
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let diff = target - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
el.textContent = `${label ? label + ' ' : ''}today`;
|
||||||
|
el.classList.add('expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = Math.floor(diff / 86400000); diff -= d * 86400000;
|
||||||
|
const h = Math.floor(diff / 3600000); diff -= h * 3600000;
|
||||||
|
const m = Math.floor(diff / 60000); diff -= m * 60000;
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
|
||||||
|
const pieces = [];
|
||||||
|
if (d) pieces.push(plural(d, 'day'));
|
||||||
|
pieces.push(`${h}h ${m}m ${s}s`);
|
||||||
|
|
||||||
|
el.textContent = `${label ? label + ' in : ' : ''}${pieces.join(' ')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => els.forEach(render);
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000); // update every second
|
||||||
|
});
|
||||||
50
assets/scripts/ui/footnote-sidenotes.js
Normal file
50
assets/scripts/ui/footnote-sidenotes.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* Event listener for footnotes and sidenotes*/
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
|
||||||
|
const sup = ref.closest("sup") || ref;
|
||||||
|
// idempotent: don't insert twice
|
||||||
|
if (sup.nextElementSibling && sup.nextElementSibling.classList?.contains("footnote-sidenote")) return;
|
||||||
|
|
||||||
|
const targetId = ref.getAttribute("href").replace(/^#/, ""); // works for fn.2 or fn2
|
||||||
|
|
||||||
|
const anchor = document.getElementById(targetId);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const footdef = anchor.closest(".footdef") || anchor.parentElement;
|
||||||
|
if (!footdef) return;
|
||||||
|
|
||||||
|
// 1) Prefer leaf paragraphs to avoid div+p duplication
|
||||||
|
let paras = footdef.querySelectorAll("p.footpara");
|
||||||
|
if (!paras.length) {
|
||||||
|
// fallback: any .footpara elements that don't contain another .footpara
|
||||||
|
paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Build HTML, de-duplicating by text content
|
||||||
|
let parts = [];
|
||||||
|
if (paras.length) {
|
||||||
|
const seen = new Set();
|
||||||
|
parts = Array.from(paras).map(p => {
|
||||||
|
const txt = p.textContent.trim().replace(/\s+/g, " ");
|
||||||
|
if (seen.has(txt)) return "";
|
||||||
|
seen.add(txt);
|
||||||
|
return p.innerHTML.trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fallback: clean full block if no paras found
|
||||||
|
if (!parts.length) {
|
||||||
|
const clone = footdef.cloneNode(true);
|
||||||
|
clone.querySelectorAll("sup.footnum, a[role='doc-backlink']").forEach(n => n.remove());
|
||||||
|
parts = [clone.innerHTML.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Insert the sidenote
|
||||||
|
const sn = document.createElement("span");
|
||||||
|
sn.className = "sidenote footnote-sidenote";
|
||||||
|
sn.setAttribute("data-fn", (ref.textContent || "").trim());
|
||||||
|
sn.innerHTML = parts.join(" ");
|
||||||
|
|
||||||
|
sup.insertAdjacentElement("afterend", sn);
|
||||||
|
});
|
||||||
|
});
|
||||||
315
assets/scripts/ui/gallery-init.js
Normal file
315
assets/scripts/ui/gallery-init.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof window.BiggerPicture !== 'function') {
|
||||||
|
console.error('[gallery-init] BiggerPicture not found. Check script path.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
|
||||||
|
|
||||||
|
const isVideo = (href) => {
|
||||||
|
if (!href) return false;
|
||||||
|
href = href.toLowerCase();
|
||||||
|
return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1b) Convert MP4 links into inline <video> players
|
||||||
|
// Convert video links into inline <video> players BUT keep the <a>
|
||||||
|
const videoLinks = document.querySelectorAll('a[href]');
|
||||||
|
videoLinks.forEach(a => {
|
||||||
|
const href = a.getAttribute("href");
|
||||||
|
if (!isVideo(href)) return;
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.controls = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.style.maxWidth = "100%";
|
||||||
|
video.style.borderRadius = "6px";
|
||||||
|
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = href;
|
||||||
|
source.type = "video/" + href.split('.').pop(); // guesses correct mime
|
||||||
|
video.appendChild(source);
|
||||||
|
|
||||||
|
// Replace the link with the inline video
|
||||||
|
a.parentNode.replaceChild(video, a);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) Wrap Org-exported images so they’re clickable
|
||||||
|
const imgs = document.querySelectorAll('.figure img, img.org-svg');
|
||||||
|
imgs.forEach((img) => {
|
||||||
|
if (img.closest('a')) return; // already wrapped
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const href = img.currentSrc || img.src;
|
||||||
|
a.href = href;
|
||||||
|
a.dataset.img = href; // lets BP pre-size/raster slides
|
||||||
|
a.dataset.alt = img.alt || '';
|
||||||
|
const setDims = () => {
|
||||||
|
a.dataset.width = img.naturalWidth || img.width || 1920;
|
||||||
|
a.dataset.height = img.naturalHeight || img.height || 1080;
|
||||||
|
};
|
||||||
|
if (img.complete) setDims(); else img.addEventListener('load', setDims);
|
||||||
|
img.style.cursor = 'zoom-in';
|
||||||
|
img.parentElement.insertBefore(a, img);
|
||||||
|
a.appendChild(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) One global BP instance
|
||||||
|
const bp = BiggerPicture({ target: document.body });
|
||||||
|
|
||||||
|
// SVG pan/zoom handle
|
||||||
|
let activePanZoom = null;
|
||||||
|
const destroyPanZoom = () => { try { activePanZoom?.destroy(); } catch(_){} activePanZoom = null; };
|
||||||
|
|
||||||
|
// Simple rotate state (for non-SVG images)
|
||||||
|
let activeContainer = null;
|
||||||
|
let currentRotation = 0;
|
||||||
|
let rotateControls = null;
|
||||||
|
|
||||||
|
// 3) Build galleries per content container
|
||||||
|
const containers = document.querySelectorAll('main, article, .content, body');
|
||||||
|
containers.forEach((container) => {
|
||||||
|
const links = Array.from(container.querySelectorAll(
|
||||||
|
'.figure a, a:has(img.org-svg), a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"], a[href$=".m4v"], a[href$=".ogv"]'
|
||||||
|
));
|
||||||
|
if (!links.length) return;
|
||||||
|
|
||||||
|
// Start the lightbox on click
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll(".theme-toggle").forEach(el => {
|
||||||
|
el.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
bp.open({
|
||||||
|
// IMPORTANT: pass the anchor ELEMENTS, not custom objects
|
||||||
|
items: links,
|
||||||
|
el: link,
|
||||||
|
caption: (el) => el.querySelector('img')?.alt || el.title || '',
|
||||||
|
maxZoom: 40, // for raster images (PNG/JPG); SVG handled separately
|
||||||
|
|
||||||
|
// Fade-out polish + cleanup
|
||||||
|
onClose(containerEl) {
|
||||||
|
destroyPanZoom();
|
||||||
|
teardownRotation();
|
||||||
|
if (containerEl) containerEl.classList.add('bp-fadeout');
|
||||||
|
const themeToggle = document.querySelector(".theme-toggle");
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called once after open and on every slide change
|
||||||
|
//onOpen(containerEl) { setupRotation(containerEl); enhanceSVG(containerEl); }
|
||||||
|
onOpen(containerEl) {
|
||||||
|
const linkEl = bp.currItem;
|
||||||
|
const href = linkEl?.href || "";
|
||||||
|
|
||||||
|
if (href.endsWith(".mp4")) {
|
||||||
|
// Turn off image rotation (not applicable for video)
|
||||||
|
teardownRotation();
|
||||||
|
|
||||||
|
const htmlLayer = containerEl.querySelector(".bp-html");
|
||||||
|
const imgLayer = containerEl.querySelector(".bp-img");
|
||||||
|
|
||||||
|
if (!htmlLayer) return;
|
||||||
|
|
||||||
|
// Hide the default image layer
|
||||||
|
if (imgLayer) imgLayer.style.display = "none";
|
||||||
|
|
||||||
|
// Insert video player
|
||||||
|
htmlLayer.innerHTML = `
|
||||||
|
<video controls autoplay style="max-width:95vw; max-height:95vh">
|
||||||
|
<source src="${href}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return; // Do not run image/SVG enhancements
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image behaviour
|
||||||
|
setupRotation(containerEl);
|
||||||
|
enhanceSVG(containerEl);
|
||||||
|
} ,
|
||||||
|
onUpdate(containerEl){ setupRotation(containerEl); enhanceSVG(containerEl); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) Simple rotate buttons for raster images
|
||||||
|
function ensureRotateControls() {
|
||||||
|
if (rotateControls) return rotateControls;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'bp-rotate-controls';
|
||||||
|
Object.assign(wrapper.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.5rem',
|
||||||
|
right: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
zIndex: '9999',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
const mkBtn = (label, title) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.title = title;
|
||||||
|
btn.setAttribute('aria-label', title);
|
||||||
|
Object.assign(btn.style, {
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'rgba(30,30,30,0.8)',
|
||||||
|
color: '#fff'
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const left = mkBtn('⟲', 'Rotate image 90° left');
|
||||||
|
const right = mkBtn('⟳', 'Rotate image 90° right');
|
||||||
|
|
||||||
|
left.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // don’t close the lightbox
|
||||||
|
if (!activeContainer) return;
|
||||||
|
currentRotation = (currentRotation - 90 + 360) % 360;
|
||||||
|
applyRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
right.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!activeContainer) return;
|
||||||
|
currentRotation = (currentRotation + 90) % 360;
|
||||||
|
applyRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.append(left, right);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
rotateControls = wrapper;
|
||||||
|
return rotateControls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRotateWrapper() {
|
||||||
|
if (!activeContainer) return null;
|
||||||
|
|
||||||
|
const imgRoot = activeContainer.querySelector('.bp-img');
|
||||||
|
if (!imgRoot) return null;
|
||||||
|
|
||||||
|
let imgEl = imgRoot.querySelector('img');
|
||||||
|
if (!imgEl) return null;
|
||||||
|
|
||||||
|
const src = (imgEl.currentSrc || imgEl.src || '').toLowerCase();
|
||||||
|
// We only rotate raster images; SVGs are handled via svg-pan-zoom
|
||||||
|
if (src.endsWith('.svg')) return null;
|
||||||
|
|
||||||
|
let wrapper = imgRoot.querySelector('.bp-rotate-wrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'bp-rotate-wrapper';
|
||||||
|
wrapper.style.display = 'inline-block';
|
||||||
|
wrapper.style.transformOrigin = 'center center';
|
||||||
|
|
||||||
|
imgRoot.appendChild(wrapper);
|
||||||
|
wrapper.appendChild(imgEl);
|
||||||
|
} else if (!wrapper.contains(imgEl)) {
|
||||||
|
// Slide changed and BiggerPicture replaced the <img>
|
||||||
|
wrapper.innerHTML = '';
|
||||||
|
wrapper.appendChild(imgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRotation() {
|
||||||
|
const wrapper = ensureRotateWrapper();
|
||||||
|
if (!wrapper) return;
|
||||||
|
wrapper.style.transform = `rotate(${currentRotation}deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRotation(containerEl) {
|
||||||
|
activeContainer = containerEl;
|
||||||
|
currentRotation = 0;
|
||||||
|
const controls = ensureRotateControls();
|
||||||
|
controls.style.display = 'flex';
|
||||||
|
applyRotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownRotation() {
|
||||||
|
activeContainer = null;
|
||||||
|
currentRotation = 0;
|
||||||
|
if (rotateControls) {
|
||||||
|
rotateControls.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 4) If current slide is an SVG, swap to inline + enable svg-pan-zoom
|
||||||
|
async function enhanceSVG(containerEl) {
|
||||||
|
try {
|
||||||
|
destroyPanZoom();
|
||||||
|
|
||||||
|
const imgEl = containerEl.querySelector('.bp-img img');
|
||||||
|
if (!imgEl) return;
|
||||||
|
|
||||||
|
const src = imgEl.currentSrc || imgEl.src || '';
|
||||||
|
const isSVG = src.toLowerCase().endsWith('.svg');
|
||||||
|
const htmlLayer = containerEl.querySelector('.bp-html');
|
||||||
|
if (!isSVG || !htmlLayer) {
|
||||||
|
// ensure any previous holder is removed and bitmap is visible
|
||||||
|
const old = htmlLayer?.querySelector('.bp-svg-holder');
|
||||||
|
if (old) old.remove();
|
||||||
|
imgEl.style.visibility = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create/clear holder
|
||||||
|
let holder = htmlLayer.querySelector('.bp-svg-holder');
|
||||||
|
if (!holder) {
|
||||||
|
holder = document.createElement('div');
|
||||||
|
holder.className = 'bp-svg-holder';
|
||||||
|
holder.style.maxWidth = '95vw';
|
||||||
|
holder.style.maxHeight = '95vh';
|
||||||
|
htmlLayer.appendChild(holder);
|
||||||
|
}
|
||||||
|
holder.innerHTML = '';
|
||||||
|
|
||||||
|
// Hide the bitmap so only the inline SVG shows
|
||||||
|
imgEl.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
// Inline the SVG
|
||||||
|
const res = await fetch(src, { cache: 'force-cache' });
|
||||||
|
const text = await res.text();
|
||||||
|
holder.innerHTML = text;
|
||||||
|
|
||||||
|
const svg = holder.querySelector('svg');
|
||||||
|
if (!svg) { imgEl.style.visibility = ''; return; }
|
||||||
|
|
||||||
|
svg.style.maxWidth = '95vw';
|
||||||
|
svg.style.maxHeight = '95vh';
|
||||||
|
svg.style.display = 'block';
|
||||||
|
|
||||||
|
if (typeof window.svgPanZoom === 'function') {
|
||||||
|
activePanZoom = svgPanZoom(svg, {
|
||||||
|
zoomEnabled: true,
|
||||||
|
controlIconsEnabled: true,
|
||||||
|
fit: true,
|
||||||
|
center: true,
|
||||||
|
minZoom: 0.05,
|
||||||
|
maxZoom: 400, // effectively "unlimited"
|
||||||
|
zoomScaleSensitivity: 0.25,
|
||||||
|
dblClickZoomEnabled: true
|
||||||
|
});
|
||||||
|
// Keep wheel inside lightbox
|
||||||
|
holder.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
|
||||||
|
} else {
|
||||||
|
console.warn('[gallery-init] svg-pan-zoom not loaded');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[gallery-init] SVG enhance failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
101
assets/scripts/ui/mermaid-diagrams.js
Normal file
101
assets/scripts/ui/mermaid-diagrams.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Make Mermaid diagrams use the active site theme and zoom controls.
|
||||||
|
(function () {
|
||||||
|
function cssVar(name, fallback) {
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mermaidConfig() {
|
||||||
|
return {
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: "base",
|
||||||
|
themeVariables: {
|
||||||
|
background: cssVar("--bg", "#faf9f6"),
|
||||||
|
mainBkg: cssVar("--surface", "#ffffff"),
|
||||||
|
primaryColor: cssVar("--surface", "#ffffff"),
|
||||||
|
primaryTextColor: cssVar("--fg", "#000000"),
|
||||||
|
primaryBorderColor: cssVar("--accent", "#2563eb"),
|
||||||
|
secondaryColor: cssVar("--surface-soft", "#f3f1eb"),
|
||||||
|
tertiaryColor: cssVar("--bg", "#faf9f6"),
|
||||||
|
clusterBkg: cssVar("--surface-soft", "#f3f1eb"),
|
||||||
|
clusterBorder: cssVar("--border", "#d7d7d7"),
|
||||||
|
lineColor: cssVar("--border", "#d7d7d7"),
|
||||||
|
textColor: cssVar("--fg", "#000000"),
|
||||||
|
edgeLabelBackground: cssVar("--surface", "#ffffff"),
|
||||||
|
fontFamily: cssVar("--font-body", "Noto, system-ui, sans-serif")
|
||||||
|
},
|
||||||
|
flowchart: {
|
||||||
|
htmlLabels: true,
|
||||||
|
curve: "basis"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMermaidSource(source) {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.innerHTML = source;
|
||||||
|
return textarea.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMermaid() {
|
||||||
|
if (!window.mermaid) return;
|
||||||
|
const diagrams = document.querySelectorAll(".mermaid");
|
||||||
|
if (!diagrams.length) return;
|
||||||
|
|
||||||
|
diagrams.forEach((el) => {
|
||||||
|
if (!el.dataset.source) {
|
||||||
|
el.dataset.source = decodeMermaidSource(el.textContent || el.innerHTML);
|
||||||
|
}
|
||||||
|
el.removeAttribute("data-processed");
|
||||||
|
el.innerHTML = el.dataset.source;
|
||||||
|
});
|
||||||
|
|
||||||
|
mermaid.initialize(mermaidConfig());
|
||||||
|
await mermaid.run({ querySelector: ".mermaid" });
|
||||||
|
wrapMermaidDiagrams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapMermaidDiagrams() {
|
||||||
|
document.querySelectorAll(".mermaid").forEach((el) => {
|
||||||
|
if (el.closest(".mermaid-container")) return;
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "mermaid-container";
|
||||||
|
if ((el.dataset.source || "").includes('root(["zxh"])')) {
|
||||||
|
container.classList.add("mermaid-container--site-map");
|
||||||
|
}
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "mermaid-zoom-controls";
|
||||||
|
controls.innerHTML = `
|
||||||
|
<button class="zoom-in" type="button" aria-label="Zoom in">+</button>
|
||||||
|
<button class="zoom-out" type="button" aria-label="Zoom out">-</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
el.replaceWith(container);
|
||||||
|
container.appendChild(controls);
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
let scale = 1;
|
||||||
|
const setScale = (next) => {
|
||||||
|
const svg = container.querySelector(".mermaid svg");
|
||||||
|
if (!svg) return;
|
||||||
|
scale = Math.max(0.2, Math.min(3, next));
|
||||||
|
svg.style.transform = `scale(${scale})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
controls.querySelector(".zoom-in").addEventListener("click", () => setScale(scale + 0.1));
|
||||||
|
controls.querySelector(".zoom-out").addEventListener("click", () => setScale(scale - 0.1));
|
||||||
|
container.addEventListener("wheel", (e) => {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setScale(scale + (e.deltaY < 0 ? 0.05 : -0.05));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", renderMermaid);
|
||||||
|
} else {
|
||||||
|
renderMermaid();
|
||||||
|
}
|
||||||
|
})();
|
||||||
88
assets/scripts/ui/toc-active-link.js
Normal file
88
assets/scripts/ui/toc-active-link.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/* Event listener for scrolling and changing the active label on the TOC */
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const toc = document.querySelector("#text-table-of-contents");
|
||||||
|
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
|
||||||
|
|
||||||
|
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
||||||
|
// <a href="#introduction">Intro</a> matches
|
||||||
|
if (!links.length) { console.warn("No ToC links found"); return; }
|
||||||
|
|
||||||
|
// Map: id -> link
|
||||||
|
const linkById = new Map();
|
||||||
|
links.forEach(a => {
|
||||||
|
const id = decodeURIComponent(a.getAttribute("href").slice(1));
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) linkById.set(id, a);
|
||||||
|
});
|
||||||
|
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
|
||||||
|
|
||||||
|
// Headings to observe (h2–h4 usually)
|
||||||
|
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
||||||
|
.filter(h => linkById.has(h.id));
|
||||||
|
|
||||||
|
// Helper to mark active
|
||||||
|
const setActive = (id) => {
|
||||||
|
links.forEach(a => {
|
||||||
|
const active = a.getAttribute("href") === `#${id}`;
|
||||||
|
a.classList.toggle("is-active", active);
|
||||||
|
if (active) a.setAttribute("aria-current", "true");
|
||||||
|
else a.removeAttribute("aria-current");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate sticky header offset in px
|
||||||
|
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
|
||||||
|
// Track visible headings (id -> distance from top)
|
||||||
|
const visible = new Map();
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const id = entry.target.id;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// How far from the top (after header offset)
|
||||||
|
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
|
||||||
|
visible.set(id, dist);
|
||||||
|
} else {
|
||||||
|
visible.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visible.size) {
|
||||||
|
// Choose the heading closest to the top (>= -headerOffset)
|
||||||
|
const topMost = [...visible.entries()]
|
||||||
|
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
|
||||||
|
setActive(topMost);
|
||||||
|
// console.log("Active:", topMost, visible);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
root: null, // track relative to viewport
|
||||||
|
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
|
||||||
|
threshold: [0, 0.01, 0.1] // fire as soon as it enters
|
||||||
|
});
|
||||||
|
|
||||||
|
headings.forEach(h => observer.observe(h));
|
||||||
|
|
||||||
|
// Initial highlight (in case load mid‑page)
|
||||||
|
let bestId = null, bestDist = Infinity;
|
||||||
|
headings.forEach(h => {
|
||||||
|
const top = h.getBoundingClientRect().top - headerOffsetPx;
|
||||||
|
const dist = top < 0 ? Math.abs(top) : top + 1e6;
|
||||||
|
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
|
||||||
|
});
|
||||||
|
if (bestId) setActive(bestId);
|
||||||
|
|
||||||
|
// smooth-scroll ToC clicks
|
||||||
|
toc.addEventListener("click", (e) => {
|
||||||
|
const a = e.target.closest('a[href^="#"]');
|
||||||
|
if (!a) return;
|
||||||
|
const id = decodeURIComponent(a.hash.slice(1));
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
el.setAttribute("tabindex", "-1");
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
history.pushState(null, "", `#${id}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
assets/scripts/vendor/bigger-picture.min.js
vendored
Normal file
1
assets/scripts/vendor/bigger-picture.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3475
assets/scripts/vendor/lunr.js
vendored
Normal file
3475
assets/scripts/vendor/lunr.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2811
assets/scripts/vendor/mermaid.min.js
vendored
Normal file
2811
assets/scripts/vendor/mermaid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
27
assets/scripts/vendor/svg-pan-zoom.min.js
vendored
Normal file
27
assets/scripts/vendor/svg-pan-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,313 +1,7 @@
|
|||||||
/* Home dashboard behaviour. All selectors are db-* scoped. */
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var script = document.createElement("script");
|
||||||
function updateGreeting() {
|
script.src = "/assets/scripts/pages/home-dashboard.js";
|
||||||
const el = document.getElementById("db-greeting");
|
script.defer = true;
|
||||||
if (!el) return;
|
document.head.appendChild(script);
|
||||||
|
}());
|
||||||
const hour = new Date().getHours();
|
|
||||||
if (hour < 5) el.textContent = "Late session";
|
|
||||||
else if (hour < 12) el.textContent = "Good morning";
|
|
||||||
else if (hour < 17) el.textContent = "Good afternoon";
|
|
||||||
else if (hour < 21) el.textContent = "Good evening";
|
|
||||||
else el.textContent = "Evening review";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateClock() {
|
|
||||||
const el = document.getElementById("db-clock");
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
el.textContent = new Intl.DateTimeFormat(undefined, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}).format(new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIsoWeek(date) {
|
|
||||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
||||||
const dayNum = d.getUTCDay() || 7;
|
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
||||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
||||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStats() {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const start = new Date(year, 0, 1);
|
|
||||||
const nextYear = new Date(year + 1, 0, 1);
|
|
||||||
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
|
|
||||||
const daysInYear = Math.round((nextYear - start) / 86400000);
|
|
||||||
const daysLeft = Math.max(0, daysInYear - dayOfYear);
|
|
||||||
const month = new Intl.DateTimeFormat(undefined, { month: "short" }).format(now);
|
|
||||||
const pct = ((dayOfYear / daysInYear) * 100).toFixed(1);
|
|
||||||
|
|
||||||
setText("db-stat-day", dayOfYear);
|
|
||||||
setText("db-stat-week", "W" + getIsoWeek(now));
|
|
||||||
setText("db-stat-month", month);
|
|
||||||
setText("db-stat-left", daysLeft);
|
|
||||||
setText("db-year-pct", pct + "%");
|
|
||||||
|
|
||||||
const fill = document.getElementById("db-year-fill");
|
|
||||||
if (fill) fill.style.width = pct + "%";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setText(id, value) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escAttr(str) {
|
|
||||||
return escHtml(str).replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normaliseHref(href, prefix) {
|
|
||||||
if (!href || href.startsWith("http") || href.startsWith("#")) return null;
|
|
||||||
if (href.startsWith("/")) return href;
|
|
||||||
const base = (prefix || "").replace(/\/$/, "");
|
|
||||||
if (!base || href.startsWith(base + "/")) return href;
|
|
||||||
return base + "/" + href;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTagLink(a) {
|
|
||||||
const href = a.getAttribute("href") || "";
|
|
||||||
return href.includes("/tags/") || Boolean(a.querySelector(".post-tag"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLinks(doc, max, prefix) {
|
|
||||||
const items = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const links = doc.querySelectorAll("#content li a[href], .org-ul li a[href], ul li a[href]");
|
|
||||||
|
|
||||||
for (const a of links) {
|
|
||||||
if (isTagLink(a)) continue;
|
|
||||||
|
|
||||||
const href = normaliseHref(a.getAttribute("href"), prefix);
|
|
||||||
const title = a.textContent.trim();
|
|
||||||
if (!href || !title || seen.has(href)) continue;
|
|
||||||
|
|
||||||
const li = a.closest("li");
|
|
||||||
const dateMatch = li ? li.textContent.match(/\b\d{4}-\d{2}-\d{2}\b/) : null;
|
|
||||||
seen.add(href);
|
|
||||||
items.push({ href, title, date: dateMatch ? dateMatch[0] : null });
|
|
||||||
if (items.length >= max) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFeed(ulId, items) {
|
|
||||||
const ul = document.getElementById(ulId);
|
|
||||||
if (!ul || !items.length) return;
|
|
||||||
|
|
||||||
ul.innerHTML = items
|
|
||||||
.map(({ href, title, date }) => (
|
|
||||||
`<li class="db-feed__item">` +
|
|
||||||
`<span class="db-feed__dot"></span>` +
|
|
||||||
`<a href="${escHtml(href)}">${escHtml(title)}</a>` +
|
|
||||||
(date ? `<span class="db-feed__meta">${escHtml(date)}</span>` : "") +
|
|
||||||
`</li>`
|
|
||||||
))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFeed(listUrl, ulId, max, prefix) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(listUrl, { credentials: "same-origin" });
|
|
||||||
if (!resp.ok) return;
|
|
||||||
|
|
||||||
const html = await resp.text();
|
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
||||||
renderFeed(ulId, extractLinks(doc, max, prefix));
|
|
||||||
} catch (_) {
|
|
||||||
/* Fallback links remain in the HTML. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commentSlug(comment) {
|
|
||||||
return comment.pageSlug || comment.page_slug || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function commentDate(comment) {
|
|
||||||
return comment.created_at || comment.createdAt || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function commentHref(page, comment) {
|
|
||||||
if (!page?.url) return null;
|
|
||||||
if (!comment.id) return page.url + "#comments";
|
|
||||||
return page.url + "#comment-" + encodeURIComponent(comment.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCommentDate(value) {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return "";
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commentExcerpt(content) {
|
|
||||||
const text = String(content || "").replace(/\s+/g, " ").trim();
|
|
||||||
if (text.length <= 150) return text;
|
|
||||||
return text.slice(0, 147).trimEnd() + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecentComments(comments, pageMap) {
|
|
||||||
const ul = document.getElementById("db-feed-comments");
|
|
||||||
if (!ul) return;
|
|
||||||
|
|
||||||
if (!comments.length) {
|
|
||||||
ul.innerHTML = (
|
|
||||||
`<li class="db-comment-feed__item">` +
|
|
||||||
`<span class="db-feed__dot"></span>` +
|
|
||||||
`<div class="db-comment-feed__body">` +
|
|
||||||
`<span class="db-comment-feed__empty">No comments yet.</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</li>`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.innerHTML = comments.map((comment) => {
|
|
||||||
const slug = commentSlug(comment);
|
|
||||||
const page = pageMap.get(slug);
|
|
||||||
const title = page?.title || slug || "Unknown page";
|
|
||||||
const href = commentHref(page, comment);
|
|
||||||
const author = comment.author || "Anonymous";
|
|
||||||
const date = formatCommentDate(commentDate(comment));
|
|
||||||
const pageLink = href
|
|
||||||
? `<a class="db-comment-feed__page" href="${escAttr(href)}">${escHtml(title)}</a>`
|
|
||||||
: `<span class="db-comment-feed__page">${escHtml(title)}</span>`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
`<li class="db-comment-feed__item">` +
|
|
||||||
`<span class="db-feed__dot"></span>` +
|
|
||||||
`<div class="db-comment-feed__body">` +
|
|
||||||
`<div class="db-comment-feed__top">` +
|
|
||||||
`<strong>${escHtml(author)}</strong>` +
|
|
||||||
`<span>on</span>` +
|
|
||||||
pageLink +
|
|
||||||
(date ? `<time datetime="${escAttr(commentDate(comment))}">${escHtml(date)}</time>` : "") +
|
|
||||||
`</div>` +
|
|
||||||
`<p>${escHtml(commentExcerpt(comment.content))}</p>` +
|
|
||||||
`</div>` +
|
|
||||||
`</li>`
|
|
||||||
);
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCommentPageMap() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/assets/content/comment-pages.json", { credentials: "same-origin" });
|
|
||||||
if (!resp.ok) return new Map();
|
|
||||||
|
|
||||||
const pages = await resp.json();
|
|
||||||
return new Map(
|
|
||||||
pages
|
|
||||||
.filter((page) => page.slug && page.url)
|
|
||||||
.map((page) => [page.slug, page])
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRecentComments() {
|
|
||||||
const ul = document.getElementById("db-feed-comments");
|
|
||||||
if (!ul) return;
|
|
||||||
|
|
||||||
const renderUnavailable = () => {
|
|
||||||
ul.innerHTML = (
|
|
||||||
`<li class="db-comment-feed__item">` +
|
|
||||||
`<span class="db-feed__dot"></span>` +
|
|
||||||
`<div class="db-comment-feed__body">` +
|
|
||||||
`<span class="db-comment-feed__empty">Recent comments are unavailable.</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</li>`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [pageMap, resp] = await Promise.all([
|
|
||||||
loadCommentPageMap(),
|
|
||||||
fetch("/api/comments", { credentials: "same-origin" }),
|
|
||||||
]);
|
|
||||||
if (!resp.ok) {
|
|
||||||
renderUnavailable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const comments = await resp.json();
|
|
||||||
const recent = comments
|
|
||||||
.filter((comment) => commentDate(comment))
|
|
||||||
.sort((a, b) => new Date(commentDate(b)) - new Date(commentDate(a)))
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
renderRecentComments(recent, pageMap);
|
|
||||||
} catch (_) {
|
|
||||||
renderUnavailable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initCommandFilter() {
|
|
||||||
const input = document.getElementById("db-command-search");
|
|
||||||
const nav = document.getElementById("db-quicknav");
|
|
||||||
if (!input || !nav) return;
|
|
||||||
|
|
||||||
const items = Array.from(nav.querySelectorAll(".db-qn-item"));
|
|
||||||
const applyFilter = () => {
|
|
||||||
const query = input.value.trim().toLowerCase();
|
|
||||||
items.forEach((item) => {
|
|
||||||
const haystack = [
|
|
||||||
item.textContent,
|
|
||||||
item.getAttribute("href"),
|
|
||||||
item.dataset.keywords,
|
|
||||||
].join(" ").toLowerCase();
|
|
||||||
item.classList.toggle("is-hidden", Boolean(query && !haystack.includes(query)));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener("input", applyFilter);
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "/" && !/^(input|textarea|select)$/i.test(event.target.tagName)) {
|
|
||||||
event.preventDefault();
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
updateGreeting();
|
|
||||||
updateClock();
|
|
||||||
updateStats();
|
|
||||||
initCommandFilter();
|
|
||||||
|
|
||||||
setInterval(updateClock, 1000);
|
|
||||||
loadFeed("/blogs/blogs-list.html", "db-feed-blogs", 6, "/blogs");
|
|
||||||
loadFeed("/posts/posts-list.html", "db-feed-posts", 6, "/posts");
|
|
||||||
loadFeed("/recently-updated.html", "db-feed-recent", 6, "");
|
|
||||||
loadRecentComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
226
assets/styles/base/elements.css
Normal file
226
assets/styles/base/elements.css
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Noto';
|
||||||
|
src: url('../../fonts/NotoSans-Regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
html, body{
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, var(--theme-glow-wine, transparent), transparent 28%),
|
||||||
|
radial-gradient(circle at 84% 18%, var(--theme-glow-green, transparent), transparent 24%),
|
||||||
|
linear-gradient(135deg, var(--theme-bg-start, var(--bg)) 0%, var(--theme-bg-mid, var(--bg)) 42%, var(--theme-bg-end, var(--bg)) 100%);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: background-color .3s, color .3s;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Noto', system-ui, sans-serif;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] body {
|
||||||
|
--theme-glow-wine: rgba(125, 47, 54, 0.24);
|
||||||
|
--theme-glow-green: rgba(68, 106, 87, 0.24);
|
||||||
|
--theme-bg-start: #0f0e0b;
|
||||||
|
--theme-bg-mid: #17140f;
|
||||||
|
--theme-bg-end: #211b14;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.16;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
|
||||||
|
background-size: 34px 34px, 34px 34px;
|
||||||
|
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in oklab, var(--accent) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3{
|
||||||
|
color: var(--heading);
|
||||||
|
line-height: 1.18;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 2.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--heading);
|
||||||
|
background: color-mix(in oklab, var(--surface-soft) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
border-left: 4px solid var(--heading);
|
||||||
|
background: color-mix(in oklab, var(--surface-soft) 72%, transparent);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: color-mix(in oklab, var(--fg) 84%, var(--muted) 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 1rem 0 1.5rem 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li::before {
|
||||||
|
content: "•";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
color: var(--heading);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
counter-reset: list-counter;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol li {
|
||||||
|
counter-increment: list-counter;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol li::before {
|
||||||
|
content: counter(list-counter) ".";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
color: var(--heading);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flow-root;
|
||||||
|
}
|
||||||
|
|
||||||
|
li::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li ul,
|
||||||
|
li ol {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li ul li::before {
|
||||||
|
content: "–";
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--note-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
li ol li::before {
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--note-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.epigraph {
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 80%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epigraph blockquote {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-left: 4px solid var(--heading);
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epigraph blockquote footer {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--note-color);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epigraph blockquote cite {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.epigraph blockquote::before,
|
||||||
|
.epigraph blockquote::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.epigraph blockquote{
|
||||||
|
border-left-color: var(--heading);
|
||||||
|
background-color: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.epigraph blockquote footer{ color: var(--muted); }
|
||||||
|
|
||||||
|
|
||||||
9
assets/styles/bigger-picture.min.css
vendored
9
assets/styles/bigger-picture.min.css
vendored
@@ -1,8 +1 @@
|
|||||||
/**
|
@import url("./vendor/bigger-picture.min.css");
|
||||||
* Minified by jsDelivr using clean-css v5.3.2.
|
|
||||||
* Original file: /npm/bigger-picture@1.1.19/dist/bigger-picture.css
|
|
||||||
*
|
|
||||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
|
||||||
*/
|
|
||||||
@keyframes bp-fadein{from{opacity:.01}to{opacity:1}}@keyframes bp-bar{from{transform:translateX(-100%)}to{transform:translateX(0)}}@keyframes bp-o{from{transform:rotate(0)}to{transform:rotate(360deg)}}.bp-wrap{top:0;left:0;width:100%;height:100%;position:fixed;z-index:999;contain:strict;touch-action:none;-webkit-tap-highlight-color:transparent}.bp-wrap>div:first-child{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.75);animation:bp-fadein .48s cubic-bezier(.215,.61,.355,1)}.bp-vid audio{position:absolute;left:14px;width:calc(100% - 28px);bottom:14px;height:50px}.bp-inner{top:0;left:0;width:100%;height:100%;position:absolute;display:flex}.bp-html{display:contents}.bp-html>:first-child{margin:auto}.bp-img-wrap{top:0;left:0;width:100%;height:100%;position:absolute;contain:strict}.bp-img-wrap .bp-canzoom{cursor:zoom-in}.bp-img-wrap .bp-drag{cursor:grabbing}.bp-close{contain:layout size}.bp-img{position:absolute;top:50%;left:50%;user-select:none;background-size:100% 100%}.bp-img div,.bp-img img{position:absolute;top:0;left:0;width:100%;height:100%}.bp-img .bp-o{display:none}.bp-zoomed .bp-img:not(.bp-drag){cursor:grab}.bp-zoomed .bp-cap{opacity:0;animation:none!important}.bp-zoomed.bp-small .bp-controls{opacity:0}.bp-zoomed.bp-small .bp-controls button{pointer-events:none}.bp-controls{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;text-align:left;transition:opacity .3s;animation:bp-fadein .3s}.bp-controls button{pointer-events:auto;cursor:pointer;position:absolute;border:0;background:rgba(0,0,0,.15);opacity:.9;transition:all .1s;contain:content}.bp-controls button:hover{background-color:rgba(0,0,0,.2);opacity:1}.bp-controls svg{fill:#fff}.bp-count{position:absolute;color:rgba(255,255,255,.9);line-height:1;margin:16px;height:50px;width:100px}.bp-next,.bp-prev{top:50%;right:0;margin-top:-32px;height:64px;width:58px;border-radius:3px 0 0 3px}.bp-next:hover:before,.bp-prev:hover:before{transform:translateX(-2px)}.bp-next:before,.bp-prev:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z'/%3E%3C/svg%3E");position:absolute;left:7px;top:9px;width:46px;transition:all .2s}.bp-prev{right:auto;left:0;transform:scalex(-1)}.bp-x{top:0;right:0;height:55px;width:58px;border-radius:0 0 0 3px}.bp-x:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23fff'%3E%3Cpath d='M24 10l-2-2-6 6-6-6-2 2 6 6-6 6 2 2 6-6 6 6 2-2-6-6z'/%3E%3C/svg%3E");position:absolute;width:37px;top:8px;right:10px}.bp-if,.bp-vid{position:relative;margin:auto;background:#000;background-size:100% 100%}.bp-if div,.bp-if iframe,.bp-if video,.bp-vid div,.bp-vid iframe,.bp-vid video{top:0;left:0;width:100%;height:100%;position:absolute;border:0}.bp-load{display:flex;background-size:100% 100%;overflow:hidden;z-index:1}.bp-bar{position:absolute;top:0;left:0;height:3px;width:100%;transform:translateX(-100%);background:rgba(255,255,255,.9);border-radius:0 3px 3px 0;animation:bp-bar 4s both}.bp-o,.bp-o:after{border-radius:50%;width:90px;height:90px}.bp-o{margin:auto;border:10px solid rgba(255,255,255,.2);border-left-color:rgba(255,255,255,.9);animation:bp-o 1s infinite linear}.bp-cap{position:absolute;bottom:2%;background:rgba(9,9,9,.8);color:rgba(255,255,255,.9);border-radius:4px;max-width:95%;line-height:1.3;padding:.6em 1.2em;left:50%;transform:translateX(-50%);width:fit-content;width:-moz-fit-content;display:table;transition:opacity .3s;animation:bp-fadein .2s}.bp-cap a{color:inherit}.bp-inline{position:absolute}.bp-lock{overflow-y:hidden}.bp-lock body{overflow:scroll}.bp-noclose .bp-x{display:none}.bp-noclose:not(.bp-zoomed){touch-action:pan-y}.bp-noclose:not(.bp-zoomed) .bp-img-wrap{cursor:zoom-in}@media (prefers-reduced-motion){.bp-wrap *{animation-duration:0s!important}}@media (max-width:500px){.bp-x{height:47px;width:47px}.bp-x:before{width:34px;top:6px;right:6px}.bp-next,.bp-prev{margin-top:-27px;height:54px;width:45px}.bp-next:before,.bp-prev:before{top:7px;left:2px;width:43px}.bp-o,.bp-o:after{border-width:6px;width:60px;height:60px}.bp-count{margin:12px 10px}}
|
|
||||||
/*# sourceMappingURL=/sm/15e96278e1e731ce40eef8d6284cefc81b81dda67c3a0aa386ec893f183bd57f.map */
|
|
||||||
|
|||||||
@@ -1,118 +1 @@
|
|||||||
.comments {
|
@import url("./components/comments.css");
|
||||||
margin-top: 4rem;
|
|
||||||
padding-top: 2rem;
|
|
||||||
border-top: 1px solid var(--border-muted, #ddd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments h2 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.comments-list {
|
|
||||||
margin: 1.5rem 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-empty {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted, #777);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border-muted, #eee);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment strong {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment p {
|
|
||||||
margin: 0.25rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-date {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted, #777);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-children {
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-left: 1.25rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
border-left: 2px solid var(--border-muted, #ddd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-reply {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted, #555);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-reply:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-form {
|
|
||||||
max-width: 40rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-author {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-form span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-form input,
|
|
||||||
.comment-form textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border-muted, #ccc);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-form textarea {
|
|
||||||
min-height: 6rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-form button {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
color: var(--fg);
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-muted, #aaa);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|||||||
118
assets/styles/components/comments.css
Normal file
118
assets/styles/components/comments.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
.comments {
|
||||||
|
margin-top: 4rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--border-muted, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
margin: 1.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-empty {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-muted, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment p {
|
||||||
|
margin: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-children {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid var(--border-muted, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #555);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form input,
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-muted, #ccc);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
min-height: 6rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
color: var(--fg);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-muted, #aaa);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
251
assets/styles/components/kanban.css
Normal file
251
assets/styles/components/kanban.css
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#kanban-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column h3 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.4rem 0 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.kanban-column[data-state="completed"] {
|
||||||
|
background: var(--kb-bg-completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column[data-state="manager_review"] {
|
||||||
|
background: var(--kb-bg-manager-review);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column[data-state="in_progress"] {
|
||||||
|
background: var(--kb-bg-in-progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column[data-state="not_started"] {
|
||||||
|
background: var(--kb-bg-not-started);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column[data-state="comments"] {
|
||||||
|
background: var(--kb-bg-comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#mobile-move-panel {
|
||||||
|
display: none;
|
||||||
|
max-width: 22rem;
|
||||||
|
margin: 1.5rem auto 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel select,
|
||||||
|
#mobile-move-panel button {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
padding: 0.45rem 2rem 0.45rem 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background-color: var(--bg, #fff);
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, #666 50%),
|
||||||
|
linear-gradient(135deg, #666 50%, transparent 50%),
|
||||||
|
linear-gradient(to right, transparent, transparent);
|
||||||
|
|
||||||
|
background-position:
|
||||||
|
calc(100% - 1.2rem) 50%,
|
||||||
|
calc(100% - 0.9rem) 50%,
|
||||||
|
100% 0;
|
||||||
|
|
||||||
|
background-size:
|
||||||
|
6px 6px,
|
||||||
|
6px 6px,
|
||||||
|
2.5rem 100%;
|
||||||
|
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #666;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
#mobile-move-panel button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid #888;
|
||||||
|
background: #f8f8f8;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel button:active {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
#mobile-move-panel select:disabled {
|
||||||
|
background-color: #f3f3f3;
|
||||||
|
color: #555;
|
||||||
|
border-color: #ccc;
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
#progress-wrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: right;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
#level-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px;
|
||||||
|
|
||||||
|
background: var(--bg, #fff);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
color: var(--fg, #333);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-tab:hover {
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-tab.active {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-tab:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
76
assets/styles/components/mermaid.css
Normal file
76
assets/styles/components/mermaid.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/* Mermaid container for zooming and panning */
|
||||||
|
.mermaid-container {
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 26rem;
|
||||||
|
max-height: 800px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
|
||||||
|
position: relative;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make SVG scale properly */
|
||||||
|
.mermaid-container .mermaid svg {
|
||||||
|
transform-origin: top left; /* needed for scaling */
|
||||||
|
transition: transform 0.2s ease; /* smooth zoom */
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-container--site-map {
|
||||||
|
width: min(96vw, 1400px);
|
||||||
|
height: min(82vh, 980px);
|
||||||
|
max-height: none;
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-container--site-map .mermaid {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 3rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-container--site-map .mermaid svg {
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.mermaid-container--site-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 78vh;
|
||||||
|
margin-left: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-container--site-map .mermaid svg {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom buttons */
|
||||||
|
.mermaid-zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-zoom-controls button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-zoom-controls button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
281
assets/styles/components/notes-board.css
Normal file
281
assets/styles/components/notes-board.css
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/* =========================
|
||||||
|
NOTES BOARD
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
#notes-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-tools label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-author-filter {
|
||||||
|
min-width: min(220px, 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-author-filter:focus {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--accent) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-wall {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
NOTE CARD
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.note {
|
||||||
|
position: relative;
|
||||||
|
padding: 1rem 1rem 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08),
|
||||||
|
0 12px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
transform: rotate(var(--note-tilt, 0deg));
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* subtle randomness */
|
||||||
|
.note:nth-child(3n) { --note-tilt: -0.6deg; }
|
||||||
|
.note:nth-child(4n) { --note-tilt: 0.4deg; }
|
||||||
|
.note:nth-child(5n) { --note-tilt: -0.3deg; }
|
||||||
|
|
||||||
|
.note:hover {
|
||||||
|
transform: rotate(0deg) translateY(-4px);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 14px rgba(0, 0, 0, 0.12),
|
||||||
|
0 18px 36px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
PIN
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.note::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #c33;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
META
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.note-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-author {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-meta time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
CONTENT
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.note-content {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
MOBILE TWEAKS
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#notes-wall {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-tools {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note::before {
|
||||||
|
left: 0.75rem;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* =========================
|
||||||
|
NOTES FORM
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
#notes-form-wrapper {
|
||||||
|
margin-top: 3rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1.25rem 1.1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08),
|
||||||
|
0 14px 30px rgba(0, 0, 0, 0.06);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pin */
|
||||||
|
#notes-form::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #c33;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
LABELS
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
#notes-form label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
INPUTS
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
#notes-form input,
|
||||||
|
#notes-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-form textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* focus state */
|
||||||
|
#notes-form input:focus,
|
||||||
|
#notes-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* placeholder */
|
||||||
|
#notes-form ::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
SUBMIT BUTTON
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
#notes-form button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-form button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notes-form button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
MOBILE
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#notes-form {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
502
assets/styles/components/org-content.css
Normal file
502
assets/styles/components/org-content.css
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
|
||||||
|
#updated{
|
||||||
|
font-size: .76rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: .45rem 0 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenote,
|
||||||
|
.marginnote{
|
||||||
|
float: right;
|
||||||
|
width: var(--margin);
|
||||||
|
margin-right: calc(-1 * ((100vi - var(--content)) / 2));
|
||||||
|
padding-right: var(--gutter);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-top: .3rem;
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--note-color);
|
||||||
|
background: var(--note-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenote,
|
||||||
|
.marginnote{
|
||||||
|
display: block;
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenote + .sidenote,
|
||||||
|
.sidenote + .marginnote,
|
||||||
|
.marginnote + .sidenote,
|
||||||
|
.marginnote + .marginnote{
|
||||||
|
margin-top: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote-sidenote::before{
|
||||||
|
content: attr(data-fn) " ";
|
||||||
|
font-size: 0.85em;
|
||||||
|
vertical-align: super;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
body{ counter-reset: sidenote-counter; }
|
||||||
|
.sidenote-number:after{
|
||||||
|
counter-increment: sidenote-counter;
|
||||||
|
content: counter(sidenote-counter);
|
||||||
|
font-size: .85em;
|
||||||
|
vertical-align: super;
|
||||||
|
margin-left: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-toggle{
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullwidth{
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
height: auto;
|
||||||
|
margin: 1.25rem 0 1.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(
|
||||||
|
calc(var(--content) + var(--bleed) * 2),
|
||||||
|
var(--fullwidth-cap),
|
||||||
|
calc(100vw - 2 * var(--body-pad))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.figure .fullwidth{ width: inherit; }
|
||||||
|
.figure figcaption{
|
||||||
|
text-align: center; font-size: .95rem; color: #666; margin-top: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.post-date{
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .86em;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tag{
|
||||||
|
float: right;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
color: var(--chip-fg);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.08rem 0.5rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filetags {
|
||||||
|
margin-top: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .3rem .4rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.filetags .tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--chip-bg);
|
||||||
|
color: var(--chip-fg);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure figcaption{ color: var(--muted); }
|
||||||
|
.org-src-container{
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code{
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.05rem 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
|
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover,
|
||||||
|
.theme-toggle:focus-visible {
|
||||||
|
background-color: color-mix(in oklab, var(--accent) 12%, transparent);
|
||||||
|
color: var(--heading);
|
||||||
|
border-color: color-mix(in oklab, var(--accent) 42%, var(--border));
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.web-logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
width: 10%;
|
||||||
|
|
||||||
|
padding: .25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.sidenote .mn-fig,
|
||||||
|
.marginnote .mn-fig{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sidenote .mn-img,
|
||||||
|
.marginnote .mn-img{
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--border, #d7d7d7);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.sidenote .mn-fig figcaption,
|
||||||
|
.marginnote .mn-fig figcaption{
|
||||||
|
margin-top: .35rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--muted, #666);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sidenote img {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1em 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--fg, #222);
|
||||||
|
background: var(--bg-alt, transparent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
time.countdown {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent, #2a7fff);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.25em 0.6em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background: color-mix(in srgb, var(--accent, #2a7fff) 10%, transparent);
|
||||||
|
box-shadow: 0 0 8px rgba(0,0,0,0.1);
|
||||||
|
transition: color 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
time.countdown.expired {
|
||||||
|
color: #999;
|
||||||
|
background: none;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SEARCH */
|
||||||
|
#search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 450;
|
||||||
|
background-color: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-btn {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 2.25rem;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input:focus,
|
||||||
|
#search-btn:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
#search-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-inline-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.55rem);
|
||||||
|
right: 0;
|
||||||
|
width: min(38rem, calc(100vw - 2rem));
|
||||||
|
max-height: min(64vh, 34rem);
|
||||||
|
display: none;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in oklab, var(--surface) 94%, var(--bg));
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 78%, var(--fg));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 56px rgba(0, 0, 0, 0.22),
|
||||||
|
0 2px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions.search-inline-open .search-inline-panel {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 1rem 0.55rem;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-meta h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-pane-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-results {
|
||||||
|
min-height: 8rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.26rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover,
|
||||||
|
.search-result.active {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: color-mix(in oklab, var(--accent) 28%, var(--border));
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-title {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-preview {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-path {
|
||||||
|
color: color-mix(in oklab, var(--muted) 82%, var(--accent));
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 9rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-help {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-help span {
|
||||||
|
padding: 0.22rem 0.45rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: rgba(255, 230, 150, 0.8);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 0.15em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result.active mark {
|
||||||
|
background: rgba(255, 230, 150, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.site-actions.search-inline-open {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions.search-inline-open .site-search {
|
||||||
|
display: block;
|
||||||
|
order: 1;
|
||||||
|
flex: 1 1 calc(100% - 2.75rem);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions.search-inline-open #search-input {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions.search-inline-open #search-btn,
|
||||||
|
.site-actions.search-inline-open .theme-toggle {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-inline-panel {
|
||||||
|
position: static;
|
||||||
|
order: 3;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-height: min(58vh, 30rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-pane-results {
|
||||||
|
min-height: 7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.search-pane-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table of contents */
|
||||||
|
#table-of-contents{
|
||||||
|
float: left;
|
||||||
|
width: var(--margin);
|
||||||
|
margin-left: calc(-1 * ((100vi - var(--content)) / 2));
|
||||||
|
padding-left: var(--gutter);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
position: sticky;
|
||||||
|
top: 5.5rem;
|
||||||
|
max-height: calc(100vh - 6rem);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
font-size: .95rem;
|
||||||
|
border-right: 1px solid var(--border, #d7d7d7);
|
||||||
|
padding-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-of-contents > h2{
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
color: var(--muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
#text-table-of-contents ul{
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
#text-table-of-contents li{
|
||||||
|
margin: .25rem 0;
|
||||||
|
}
|
||||||
|
#text-table-of-contents a{
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#text-table-of-contents a:hover{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#text-table-of-contents ul ul { margin-left: .75rem; opacity: .9; }
|
||||||
|
|
||||||
|
h2[id], h3[id], h4[id] { scroll-margin-top: 6.5rem; }
|
||||||
|
|
||||||
|
#text-table-of-contents a.is-active{
|
||||||
|
text-decoration: underline;
|
||||||
|
background-color: var(--active-toc);
|
||||||
|
}
|
||||||
95
assets/styles/components/org-syntax.css
Normal file
95
assets/styles/components/org-syntax.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
.org-comment { color: #b22222; }
|
||||||
|
.org-string { color: #8b2252; }
|
||||||
|
.org-keyword { color: #a020f0; }
|
||||||
|
.org-builtin { color: #483d8b; }
|
||||||
|
.org-constant { color: #008b8b; }
|
||||||
|
.org-function-name { color: #0000ff; }
|
||||||
|
.org-variable-name { color: sienna; }
|
||||||
|
.org-type { color: #228b22; }
|
||||||
|
.org-preprocessor { color: #483d8b; }
|
||||||
|
.org-doc { color: #8b2252; }
|
||||||
|
.org-warning { color: #ff8c00; font-weight: 700; }
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] {
|
||||||
|
--syntax-comment: #a97861;
|
||||||
|
--syntax-string: #c99572;
|
||||||
|
--syntax-keyword: #d6b566;
|
||||||
|
--syntax-builtin: #b99b63;
|
||||||
|
--syntax-constant: #7fb092;
|
||||||
|
--syntax-function: #e8cf91;
|
||||||
|
--syntax-variable: #d0a35c;
|
||||||
|
--syntax-type: #8fbf9f;
|
||||||
|
--syntax-warning: #d58a55;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] .org-comment { color: var(--syntax-comment); }
|
||||||
|
:root[data-theme="dark-academia"] .org-string { color: var(--syntax-string); }
|
||||||
|
:root[data-theme="dark-academia"] .org-keyword { color: var(--syntax-keyword); }
|
||||||
|
:root[data-theme="dark-academia"] .org-builtin { color: var(--syntax-builtin); }
|
||||||
|
:root[data-theme="dark-academia"] .org-constant { color: var(--syntax-constant); }
|
||||||
|
:root[data-theme="dark-academia"] .org-function-name { color: var(--syntax-function); }
|
||||||
|
:root[data-theme="dark-academia"] .org-variable-name { color: var(--syntax-variable); }
|
||||||
|
:root[data-theme="dark-academia"] .org-type { color: var(--syntax-type); }
|
||||||
|
:root[data-theme="dark-academia"] .org-preprocessor { color: var(--syntax-builtin); }
|
||||||
|
:root[data-theme="dark-academia"] .org-doc { color: var(--syntax-string); }
|
||||||
|
:root[data-theme="dark-academia"] .org-warning { color: var(--syntax-warning); }
|
||||||
|
|
||||||
|
:not(pre) > code{
|
||||||
|
padding: 2px 5px; margin: 0 1px;
|
||||||
|
border: 1px solid var(--border); border-radius: 3px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
color: var(--fg); font-size: 80%;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-src-container{
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 3px 3px 3px color-mix(in oklab, #000 8%, transparent);
|
||||||
|
font-family: Lucida Console, monospace;
|
||||||
|
font-size: 80%;
|
||||||
|
margin: 1em auto;
|
||||||
|
padding: .1em .5em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.org-src-container > pre{ overflow: auto; }
|
||||||
|
|
||||||
|
.org-src-container > pre:before{
|
||||||
|
display: block; position: absolute; top: 0; right: 0;
|
||||||
|
background-color: var(--surface-soft); color: var(--fg);
|
||||||
|
padding: 0 .5em; border-bottom-left-radius: 8px; border: 0;
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
.org-src-container > pre.src-bash:before { content: "bash"; }
|
||||||
|
.org-src-container > pre.src-sh:before { content: "sh"; }
|
||||||
|
.org-src-container > pre.src-shell:before { content: "shell"; }
|
||||||
|
.org-src-container > pre.src-python:before { content: "Python"; }
|
||||||
|
.org-src-container > pre.src-emacs-lisp:before { content: "Emacs Lisp"; }
|
||||||
|
.org-src-container > pre.src-js:before,
|
||||||
|
.org-src-container > pre.src-javascript:before { content: "Javascript"; }
|
||||||
|
.org-src-container > pre.src-typescript:before { content: "Typescript"; }
|
||||||
|
.org-src-container > pre.src-html:before { content: "HTML"; }
|
||||||
|
.org-src-container > pre.src-css:before { content: "CSS"; }
|
||||||
|
.org-src-container > pre.src-c:before { content: "C"; }
|
||||||
|
.org-src-container > pre.src-cpp:before { content: "C++"; }
|
||||||
|
.org-src-container > pre.src-java:before { content: "Java"; }
|
||||||
|
.org-src-container > pre.src-rust:before { content: "Rust"; }
|
||||||
|
.org-src-container > pre.src-R:before { content: "R"; }
|
||||||
|
|
||||||
|
.copy-btn{
|
||||||
|
position: absolute; top: .4em; right: .4em;
|
||||||
|
background-color: var(--code-bg); color: var(--heading);
|
||||||
|
border: 1px solid var(--heading); border-radius: 4px;
|
||||||
|
padding: .2em .6em; font-size: .8rem; cursor: pointer; z-index: 10;
|
||||||
|
transition: background-color .3s;
|
||||||
|
}
|
||||||
|
.copy-btn:hover{ background-color: var(--heading); color: var(--code-bg); }
|
||||||
|
|
||||||
|
pre.src::before{ content: none !important; }
|
||||||
|
pre.src{
|
||||||
|
padding: 1.5em 1em 1em;
|
||||||
|
font-family: "Fira Mono","Courier New",monospace;
|
||||||
|
font-size: .9rem; line-height: 1.4;
|
||||||
|
overflow-x: auto; white-space: pre-wrap;
|
||||||
|
}
|
||||||
289
assets/styles/features/hidden-details.css
Normal file
289
assets/styles/features/hidden-details.css
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
.hidden-detail-root {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-whisper {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline dotted color-mix(in oklab, var(--muted) 55%, transparent);
|
||||||
|
text-underline-offset: 0.22em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-whisper::selection,
|
||||||
|
.hidden-greeting::selection,
|
||||||
|
.hidden-footer-note::selection {
|
||||||
|
background: color-mix(in oklab, var(--accent) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-footer-note {
|
||||||
|
max-width: 58ch;
|
||||||
|
margin: 0.45rem auto 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
max-width: min(23rem, calc(100vw - 2rem));
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 92%, var(--bg) 8%);
|
||||||
|
color: var(--fg);
|
||||||
|
box-shadow: 0 16px 40px color-mix(in oklab, #000 18%, transparent);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.7rem);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 220ms ease, transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-toast.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-message-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-layer-message strong {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-corner-object {
|
||||||
|
position: fixed;
|
||||||
|
left: 0.65rem;
|
||||||
|
bottom: 0.65rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 76%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 84%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 1rem/1 monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.44;
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease, color 160ms ease;
|
||||||
|
z-index: 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-corner-object:hover,
|
||||||
|
.hidden-corner-object:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--fg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-greeting {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-drift-note {
|
||||||
|
position: fixed;
|
||||||
|
top: 20%;
|
||||||
|
left: 50%;
|
||||||
|
max-width: min(28rem, calc(100vw - 2rem));
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
box-shadow: 0 18px 52px color-mix(in oklab, #000 18%, transparent);
|
||||||
|
transform: translate(-50%, -0.5rem);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-drift-note pre {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-drift-note button {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-object-rail {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
opacity: 0.18;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-object-rail:hover,
|
||||||
|
.hidden-object-rail:focus-within {
|
||||||
|
opacity: 0.94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-keepsake {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in oklab, var(--surface) 78%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 0.8rem/1 monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-keepsake:hover,
|
||||||
|
.hidden-keepsake:focus-visible {
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-weather-window {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.7rem;
|
||||||
|
top: 7.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 84%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
opacity: 0.24;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-weather-window:hover,
|
||||||
|
.hidden-weather-window:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-oldweb-stamp {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 4.2rem;
|
||||||
|
max-width: 12rem;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 0.72rem/1.2 "Times New Roman", serif;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.34;
|
||||||
|
cursor: help;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-oldweb-stamp:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-guestbook-line {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-family: "Times New Roman", serif;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-hover-memory {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-hover-memory:hover::after {
|
||||||
|
content: attr(data-hidden-memory);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 100%;
|
||||||
|
width: min(28rem, 80vw);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: 'Noto', system-ui, sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
z-index: 30;
|
||||||
|
box-shadow: 0 12px 28px color-mix(in oklab, #000 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.hidden-home-takeover .db-root {
|
||||||
|
filter: saturate(0.9) contrast(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.hidden-home-takeover .db-hero::before {
|
||||||
|
content: "under construction, emotionally";
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 0.82rem "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-terminal {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-terminal input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.65rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-terminal-log {
|
||||||
|
min-height: 10rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-secret-link {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.hidden-late-night {
|
||||||
|
--hidden-night-glow: color-mix(in oklab, var(--accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.hidden-late-night::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: 18vh;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(transparent, var(--hidden-night-glow));
|
||||||
|
opacity: 0.32;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.hidden-toast {
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-corner-object {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,326 +1 @@
|
|||||||
.hidden-detail-root {
|
@import url("./features/hidden-details.css");
|
||||||
position: relative;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-whisper {
|
|
||||||
display: inline-block;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: help;
|
|
||||||
text-decoration: underline dotted color-mix(in oklab, var(--muted) 55%, transparent);
|
|
||||||
text-underline-offset: 0.22em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-whisper::selection,
|
|
||||||
.hidden-greeting::selection,
|
|
||||||
.hidden-footer-note::selection {
|
|
||||||
background: color-mix(in oklab, var(--accent) 32%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-footer-note {
|
|
||||||
max-width: 58ch;
|
|
||||||
margin: 0.45rem auto 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.82;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-toast {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
max-width: min(23rem, calc(100vw - 2rem));
|
|
||||||
padding: 0.8rem 0.95rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 92%, var(--bg) 8%);
|
|
||||||
color: var(--fg);
|
|
||||||
box-shadow: 0 16px 40px color-mix(in oklab, #000 18%, transparent);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(0.7rem);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 220ms ease, transform 220ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-toast.is-visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-hidden-avatar {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-toast.has-hidden-avatar,
|
|
||||||
.hidden-drift-message.has-hidden-avatar {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-footer-note.has-hidden-avatar,
|
|
||||||
.hidden-greeting.has-hidden-avatar,
|
|
||||||
.hidden-guestbook-line.has-hidden-avatar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-message-avatar {
|
|
||||||
width: 2.1rem;
|
|
||||||
height: 2.1rem;
|
|
||||||
flex: 0 0 2.1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--hidden-avatar-glow, var(--accent)) 62%, var(--border));
|
|
||||||
box-shadow: 0 0 18px color-mix(in oklab, var(--hidden-avatar-glow, var(--accent)) 34%, transparent);
|
|
||||||
background: color-mix(in oklab, var(--surface) 82%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-message-text {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-layer-message strong {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-terminal-log .has-hidden-avatar {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-terminal-log .hidden-message-avatar {
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-corner-object {
|
|
||||||
position: fixed;
|
|
||||||
left: 0.65rem;
|
|
||||||
bottom: 0.65rem;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 76%, transparent);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 84%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
font: 1rem/1 monospace;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.44;
|
|
||||||
transition: opacity 160ms ease, transform 160ms ease, color 160ms ease;
|
|
||||||
z-index: 21;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-corner-object:hover,
|
|
||||||
.hidden-corner-object:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--fg);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-greeting {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-drift-note {
|
|
||||||
position: fixed;
|
|
||||||
top: 20%;
|
|
||||||
left: 50%;
|
|
||||||
max-width: min(28rem, calc(100vw - 2rem));
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
box-shadow: 0 18px 52px color-mix(in oklab, #000 18%, transparent);
|
|
||||||
transform: translate(-50%, -0.5rem);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-drift-note pre {
|
|
||||||
margin: 0.75rem 0 0;
|
|
||||||
white-space: pre;
|
|
||||||
overflow: auto;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-drift-note button {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-object-rail {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 0.35rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
opacity: 0.18;
|
|
||||||
transition: opacity 180ms ease;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-object-rail:hover,
|
|
||||||
.hidden-object-rail:focus-within {
|
|
||||||
opacity: 0.94;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-keepsake {
|
|
||||||
width: 1.35rem;
|
|
||||||
height: 1.35rem;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: color-mix(in oklab, var(--surface) 78%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
font: 0.8rem/1 monospace;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-keepsake:hover,
|
|
||||||
.hidden-keepsake:focus-visible {
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-weather-window {
|
|
||||||
position: fixed;
|
|
||||||
right: 0.7rem;
|
|
||||||
top: 7.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 84%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 0.25rem 0.45rem;
|
|
||||||
opacity: 0.24;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-weather-window:hover,
|
|
||||||
.hidden-weather-window:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-oldweb-stamp {
|
|
||||||
position: fixed;
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 4.2rem;
|
|
||||||
max-width: 12rem;
|
|
||||||
padding: 0.25rem 0.4rem;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--muted);
|
|
||||||
font: 0.72rem/1.2 "Times New Roman", serif;
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.34;
|
|
||||||
cursor: help;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-oldweb-stamp:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-guestbook-line {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-family: "Times New Roman", serif;
|
|
||||||
opacity: 0.78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-hover-memory {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-hover-memory:hover::after {
|
|
||||||
content: attr(data-hidden-memory);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 100%;
|
|
||||||
width: min(28rem, 80vw);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
padding: 0.55rem 0.65rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-family: 'Noto', system-ui, sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.35;
|
|
||||||
z-index: 30;
|
|
||||||
box-shadow: 0 12px 28px color-mix(in oklab, #000 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.hidden-home-takeover .db-root {
|
|
||||||
filter: saturate(0.9) contrast(1.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.hidden-home-takeover .db-hero::before {
|
|
||||||
content: "under construction, emotionally";
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font: 0.82rem "Times New Roman", serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-terminal {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-terminal input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
color: var(--fg);
|
|
||||||
padding: 0.65rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-terminal-log {
|
|
||||||
min-height: 10rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-secret-link {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.hidden-late-night {
|
|
||||||
--hidden-night-glow: color-mix(in oklab, var(--accent) 9%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.hidden-late-night::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: auto 0 0;
|
|
||||||
height: 18vh;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(transparent, var(--hidden-night-glow));
|
|
||||||
opacity: 0.32;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.hidden-toast {
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-corner-object {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,251 +1 @@
|
|||||||
#kanban-board {
|
@import url("./components/kanban.css");
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.6rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column h3 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.4rem 0 0.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-list {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-height: 3rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-card {
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: grab;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-card.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.kanban-column[data-state="completed"] {
|
|
||||||
background: var(--kb-bg-completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column[data-state="manager_review"] {
|
|
||||||
background: var(--kb-bg-manager-review);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column[data-state="in_progress"] {
|
|
||||||
background: var(--kb-bg-in-progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column[data-state="not_started"] {
|
|
||||||
background: var(--kb-bg-not-started);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column[data-state="comments"] {
|
|
||||||
background: var(--kb-bg-comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#mobile-move-panel {
|
|
||||||
display: none;
|
|
||||||
max-width: 22rem;
|
|
||||||
margin: 1.5rem auto 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-color, #ddd);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
|
||||||
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel * {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel h4 {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel select,
|
|
||||||
#mobile-move-panel button {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel button {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel select {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
padding: 0.45rem 2rem 0.45rem 0.6rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-color, #ccc);
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
background-color: var(--bg, #fff);
|
|
||||||
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, transparent 50%, #666 50%),
|
|
||||||
linear-gradient(135deg, #666 50%, transparent 50%),
|
|
||||||
linear-gradient(to right, transparent, transparent);
|
|
||||||
|
|
||||||
background-position:
|
|
||||||
calc(100% - 1.2rem) 50%,
|
|
||||||
calc(100% - 0.9rem) 50%,
|
|
||||||
100% 0;
|
|
||||||
|
|
||||||
background-size:
|
|
||||||
6px 6px,
|
|
||||||
6px 6px,
|
|
||||||
2.5rem 100%;
|
|
||||||
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #666;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
#mobile-move-panel button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
border: 1px solid #888;
|
|
||||||
background: #f8f8f8;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel button:active {
|
|
||||||
background: #eee;
|
|
||||||
}
|
|
||||||
#mobile-move-panel select:disabled {
|
|
||||||
background-color: #f3f3f3;
|
|
||||||
color: #555;
|
|
||||||
border-color: #ccc;
|
|
||||||
opacity: 1;
|
|
||||||
cursor: default;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
#progress-wrapper {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-label {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-align: right;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 10px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
width: 0%;
|
|
||||||
background: #22c55e;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
#level-tabs {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 3px;
|
|
||||||
|
|
||||||
background: var(--bg, #fff);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-tab {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
padding: 0.35rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
color: var(--fg, #333);
|
|
||||||
|
|
||||||
transition:
|
|
||||||
background 0.2s ease,
|
|
||||||
color 0.2s ease,
|
|
||||||
transform 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-tab:hover {
|
|
||||||
background: rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-tab.active {
|
|
||||||
background: #333;
|
|
||||||
color: white;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-tab:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|||||||
162
assets/styles/layout/site-shell.css
Normal file
162
assets/styles/layout/site-shell.css
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
.banner-header{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
|
||||||
|
box-shadow: 0 12px 32px color-mix(in oklab, #000 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] .banner-header,
|
||||||
|
:root[data-theme="dark-academia"] .org-src-container,
|
||||||
|
:root[data-theme="dark-academia"] #notes-form,
|
||||||
|
:root[data-theme="dark-academia"] .note {
|
||||||
|
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] h1,
|
||||||
|
:root[data-theme="dark-academia"] h2,
|
||||||
|
:root[data-theme="dark-academia"] h3 {
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-brand__text {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-logo{
|
||||||
|
height: 42px;
|
||||||
|
width: 42px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover,
|
||||||
|
.site-nav a:focus-visible {
|
||||||
|
color: var(--fg);
|
||||||
|
background: color-mix(in oklab, var(--fg) 7%, transparent);
|
||||||
|
text-decoration: none;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex: 0 1 23rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 13rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preamble.status{
|
||||||
|
max-width: var(--content);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: var(--body-pad);
|
||||||
|
padding-right: var(--body-pad);
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preamble .banner-header,
|
||||||
|
#preamble #updated{
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
footer{
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-top: 3rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content.content{
|
||||||
|
max-width: var(--content);
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
padding-left: var(--body-pad);
|
||||||
|
padding-right: var(--body-pad);
|
||||||
|
box-sizing: content-box;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#content .figure,
|
||||||
|
#content img:not(.fullwidth){
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.no-sidenotes {
|
||||||
|
--body-pad: 0;
|
||||||
|
--margin: 0;
|
||||||
|
--gutter: 0;
|
||||||
|
|
||||||
|
--content-min: 80ch;
|
||||||
|
--content-max: 1200px;
|
||||||
|
|
||||||
|
--content: clamp(
|
||||||
|
var(--content-min),
|
||||||
|
100vi,
|
||||||
|
var(--content-max)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,134 +1 @@
|
|||||||
@media (max-width: 600px){
|
@import url("./utilities/responsive.css");
|
||||||
.banner-header {
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.2rem;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav a {
|
|
||||||
padding: 0.42rem 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1 1 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-search {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#updated {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.no-sidenotes {
|
|
||||||
margin-left: 1em;
|
|
||||||
margin-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-move-panel {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark){
|
|
||||||
:root:not([data-theme]){
|
|
||||||
--bg: #0f1115;
|
|
||||||
--fg: #e6e6e6;
|
|
||||||
--link: #8ab4ff;
|
|
||||||
--heading: #9ecbff;
|
|
||||||
--code-bg: #1a1d24;
|
|
||||||
|
|
||||||
--note-color: #c2c7cf;
|
|
||||||
--note-bg: transparent;
|
|
||||||
|
|
||||||
--border: #2a2f3a;
|
|
||||||
--chip-bg: #1f2330;
|
|
||||||
--chip-fg: #cfd3da;
|
|
||||||
--muted: #a9b0bb;
|
|
||||||
--surface: #151820;
|
|
||||||
--surface-soft: #1b202a;
|
|
||||||
--accent: #8ab4ff;
|
|
||||||
--bg-color: var(--surface);
|
|
||||||
--border-color: var(--border);
|
|
||||||
--border-muted: var(--border);
|
|
||||||
--text-muted: var(--muted);
|
|
||||||
--accent-bg: color-mix(in oklab, var(--accent) 12%, var(--surface));
|
|
||||||
}
|
|
||||||
.countdown-wrap {
|
|
||||||
color: var(--fg, #ddd);
|
|
||||||
}
|
|
||||||
time.countdown {
|
|
||||||
color: var(--accent, #7abfff);
|
|
||||||
background: color-mix(in srgb, var(--accent, #7abfff) 15%, transparent);
|
|
||||||
box-shadow: 0 0 6px rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
time.countdown.expired {
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1250px){
|
|
||||||
:root {
|
|
||||||
--content-min: 0px;
|
|
||||||
--content: min(var(--content-max), calc(100vi - 2 * var(--body-pad)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#preamble.status{
|
|
||||||
padding-right: var(--body-pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content.content{
|
|
||||||
padding-right: var(--body-pad);
|
|
||||||
}
|
|
||||||
.sidenote,
|
|
||||||
.marginnote{
|
|
||||||
float: none;
|
|
||||||
clear: both;
|
|
||||||
width: auto;
|
|
||||||
display: block;
|
|
||||||
margin: 0.5rem 0 0.75rem;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
border-left: 3px solid rgba(0,0,0,.08);
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
.fullwidth{
|
|
||||||
max-width: calc(100vw - 2 * var(--body-pad));
|
|
||||||
}
|
|
||||||
.sidenote, .marginnote{
|
|
||||||
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
|
|
||||||
}
|
|
||||||
.sidenote .mn-img,
|
|
||||||
.marginnote .mn-img{
|
|
||||||
border-color: color-mix(in oklab, var(--fg) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#table-of-contents{
|
|
||||||
float: none;
|
|
||||||
position: static;
|
|
||||||
width: auto;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding: .5rem .75rem;
|
|
||||||
border-right: 0;
|
|
||||||
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
|
|
||||||
background: color-mix(in oklab, var(--bg) 96%, var(--fg) 4%);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,502 +1 @@
|
|||||||
|
@import url("./components/org-content.css");
|
||||||
#updated{
|
|
||||||
font-size: .76rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: .45rem 0 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote,
|
|
||||||
.marginnote{
|
|
||||||
float: right;
|
|
||||||
width: var(--margin);
|
|
||||||
margin-right: calc(-1 * ((100vi - var(--content)) / 2));
|
|
||||||
padding-right: var(--gutter);
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
margin-top: .3rem;
|
|
||||||
margin-bottom: .6rem;
|
|
||||||
font-size: .95rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--note-color);
|
|
||||||
background: var(--note-bg);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote,
|
|
||||||
.marginnote{
|
|
||||||
display: block;
|
|
||||||
clear: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenote + .sidenote,
|
|
||||||
.sidenote + .marginnote,
|
|
||||||
.marginnote + .sidenote,
|
|
||||||
.marginnote + .marginnote{
|
|
||||||
margin-top: .4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footnote-sidenote::before{
|
|
||||||
content: attr(data-fn) " ";
|
|
||||||
font-size: 0.85em;
|
|
||||||
vertical-align: super;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
body{ counter-reset: sidenote-counter; }
|
|
||||||
.sidenote-number:after{
|
|
||||||
counter-increment: sidenote-counter;
|
|
||||||
content: counter(sidenote-counter);
|
|
||||||
font-size: .85em;
|
|
||||||
vertical-align: super;
|
|
||||||
margin-left: .15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.margin-toggle{
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullwidth{
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
height: auto;
|
|
||||||
margin: 1.25rem 0 1.75rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: min(
|
|
||||||
calc(var(--content) + var(--bleed) * 2),
|
|
||||||
var(--fullwidth-cap),
|
|
||||||
calc(100vw - 2 * var(--body-pad))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
.figure .fullwidth{ width: inherit; }
|
|
||||||
.figure figcaption{
|
|
||||||
text-align: center; font-size: .95rem; color: #666; margin-top: .4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.post-date{
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: .86em;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-tag{
|
|
||||||
float: right;
|
|
||||||
display: inline-block;
|
|
||||||
background-color: var(--chip-bg);
|
|
||||||
color: var(--chip-fg);
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.08rem 0.5rem;
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filetags {
|
|
||||||
margin-top: .5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: .3rem .4rem;
|
|
||||||
font-size: .9rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.filetags .tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: .1rem .45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--chip-bg);
|
|
||||||
color: var(--chip-fg);
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.figure figcaption{ color: var(--muted); }
|
|
||||||
.org-src-container{
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: none;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(pre) > code{
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background-color: var(--code-bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.05rem 0.28rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
|
||||||
color: var(--fg);
|
|
||||||
min-height: 2.25rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
border-radius: 7px;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 750;
|
|
||||||
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
.theme-toggle:hover,
|
|
||||||
.theme-toggle:focus-visible {
|
|
||||||
background-color: color-mix(in oklab, var(--accent) 12%, transparent);
|
|
||||||
color: var(--heading);
|
|
||||||
border-color: color-mix(in oklab, var(--accent) 42%, var(--border));
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.web-logo {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.75rem;
|
|
||||||
left: 1rem;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
width: 10%;
|
|
||||||
|
|
||||||
padding: .25rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.sidenote .mn-fig,
|
|
||||||
.marginnote .mn-fig{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.sidenote .mn-img,
|
|
||||||
.marginnote .mn-img{
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border: 1px solid var(--border, #d7d7d7);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
.sidenote .mn-fig figcaption,
|
|
||||||
.marginnote .mn-fig figcaption{
|
|
||||||
margin-top: .35rem;
|
|
||||||
font-size: .85rem;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.sidenote img {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1em 0;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--fg, #222);
|
|
||||||
background: var(--bg-alt, transparent);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
time.countdown {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent, #2a7fff);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 0.25em 0.6em;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background: color-mix(in srgb, var(--accent, #2a7fff) 10%, transparent);
|
|
||||||
box-shadow: 0 0 8px rgba(0,0,0,0.1);
|
|
||||||
transition: color 0.3s ease, background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
time.countdown.expired {
|
|
||||||
color: #999;
|
|
||||||
background: none;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SEARCH */
|
|
||||||
#search-input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 7px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 450;
|
|
||||||
background-color: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-btn {
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: 0 0 2.25rem;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-input:focus,
|
|
||||||
#search-btn:focus-visible {
|
|
||||||
outline: 0;
|
|
||||||
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
#search-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-btn {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-inline-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.55rem);
|
|
||||||
right: 0;
|
|
||||||
width: min(38rem, calc(100vw - 2rem));
|
|
||||||
max-height: min(64vh, 34rem);
|
|
||||||
display: none;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
||||||
overflow: hidden;
|
|
||||||
background: color-mix(in oklab, var(--surface) 94%, var(--bg));
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 78%, var(--fg));
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow:
|
|
||||||
0 18px 56px rgba(0, 0, 0, 0.22),
|
|
||||||
0 2px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions.search-inline-open .search-inline-panel {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.7rem 1rem 0.55rem;
|
|
||||||
border-bottom: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-meta h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-pane-count {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-results {
|
|
||||||
min-height: 8rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.26rem;
|
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 7px;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result:hover,
|
|
||||||
.search-result.active {
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border-color: color-mix(in oklab, var(--accent) 28%, var(--border));
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-title {
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-preview {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-path {
|
|
||||||
color: color-mix(in oklab, var(--muted) 82%, var(--accent));
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-empty {
|
|
||||||
display: grid;
|
|
||||||
min-height: 9rem;
|
|
||||||
place-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-help {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0.65rem 0.8rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-help span {
|
|
||||||
padding: 0.22rem 0.45rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
mark {
|
|
||||||
background: rgba(255, 230, 150, 0.8);
|
|
||||||
color: inherit;
|
|
||||||
padding: 0 0.15em;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result.active mark {
|
|
||||||
background: rgba(255, 230, 150, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.site-actions.search-inline-open {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions.search-inline-open .site-search {
|
|
||||||
display: block;
|
|
||||||
order: 1;
|
|
||||||
flex: 1 1 calc(100% - 2.75rem);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions.search-inline-open #search-input {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions.search-inline-open #search-btn,
|
|
||||||
.site-actions.search-inline-open .theme-toggle {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-inline-panel {
|
|
||||||
position: static;
|
|
||||||
order: 3;
|
|
||||||
flex: 1 1 100%;
|
|
||||||
width: 100%;
|
|
||||||
max-height: min(58vh, 30rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-pane-results {
|
|
||||||
min-height: 7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.search-pane-meta {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table of contents */
|
|
||||||
#table-of-contents{
|
|
||||||
float: left;
|
|
||||||
width: var(--margin);
|
|
||||||
margin-left: calc(-1 * ((100vi - var(--content)) / 2));
|
|
||||||
padding-left: var(--gutter);
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
position: sticky;
|
|
||||||
top: 5.5rem;
|
|
||||||
max-height: calc(100vh - 6rem);
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
font-size: .95rem;
|
|
||||||
border-right: 1px solid var(--border, #d7d7d7);
|
|
||||||
padding-top: .25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#table-of-contents > h2{
|
|
||||||
margin: 0 0 .5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
#text-table-of-contents ul{
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
#text-table-of-contents li{
|
|
||||||
margin: .25rem 0;
|
|
||||||
}
|
|
||||||
#text-table-of-contents a{
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
#text-table-of-contents a:hover{
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#text-table-of-contents ul ul { margin-left: .75rem; opacity: .9; }
|
|
||||||
|
|
||||||
h2[id], h3[id], h4[id] { scroll-margin-top: 6.5rem; }
|
|
||||||
|
|
||||||
#text-table-of-contents a.is-active{
|
|
||||||
text-decoration: underline;
|
|
||||||
background-color: var(--active-toc);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,95 +1 @@
|
|||||||
.org-comment { color: #b22222; }
|
@import url("./components/org-syntax.css");
|
||||||
.org-string { color: #8b2252; }
|
|
||||||
.org-keyword { color: #a020f0; }
|
|
||||||
.org-builtin { color: #483d8b; }
|
|
||||||
.org-constant { color: #008b8b; }
|
|
||||||
.org-function-name { color: #0000ff; }
|
|
||||||
.org-variable-name { color: sienna; }
|
|
||||||
.org-type { color: #228b22; }
|
|
||||||
.org-preprocessor { color: #483d8b; }
|
|
||||||
.org-doc { color: #8b2252; }
|
|
||||||
.org-warning { color: #ff8c00; font-weight: 700; }
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] {
|
|
||||||
--syntax-comment: #a97861;
|
|
||||||
--syntax-string: #c99572;
|
|
||||||
--syntax-keyword: #d6b566;
|
|
||||||
--syntax-builtin: #b99b63;
|
|
||||||
--syntax-constant: #7fb092;
|
|
||||||
--syntax-function: #e8cf91;
|
|
||||||
--syntax-variable: #d0a35c;
|
|
||||||
--syntax-type: #8fbf9f;
|
|
||||||
--syntax-warning: #d58a55;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] .org-comment { color: var(--syntax-comment); }
|
|
||||||
:root[data-theme="dark-academia"] .org-string { color: var(--syntax-string); }
|
|
||||||
:root[data-theme="dark-academia"] .org-keyword { color: var(--syntax-keyword); }
|
|
||||||
:root[data-theme="dark-academia"] .org-builtin { color: var(--syntax-builtin); }
|
|
||||||
:root[data-theme="dark-academia"] .org-constant { color: var(--syntax-constant); }
|
|
||||||
:root[data-theme="dark-academia"] .org-function-name { color: var(--syntax-function); }
|
|
||||||
:root[data-theme="dark-academia"] .org-variable-name { color: var(--syntax-variable); }
|
|
||||||
:root[data-theme="dark-academia"] .org-type { color: var(--syntax-type); }
|
|
||||||
:root[data-theme="dark-academia"] .org-preprocessor { color: var(--syntax-builtin); }
|
|
||||||
:root[data-theme="dark-academia"] .org-doc { color: var(--syntax-string); }
|
|
||||||
:root[data-theme="dark-academia"] .org-warning { color: var(--syntax-warning); }
|
|
||||||
|
|
||||||
:not(pre) > code{
|
|
||||||
padding: 2px 5px; margin: 0 1px;
|
|
||||||
border: 1px solid var(--border); border-radius: 3px;
|
|
||||||
background-clip: padding-box;
|
|
||||||
color: var(--fg); font-size: 80%;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background-color: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-src-container{
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 3px 3px 3px color-mix(in oklab, #000 8%, transparent);
|
|
||||||
font-family: Lucida Console, monospace;
|
|
||||||
font-size: 80%;
|
|
||||||
margin: 1em auto;
|
|
||||||
padding: .1em .5em;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.org-src-container > pre{ overflow: auto; }
|
|
||||||
|
|
||||||
.org-src-container > pre:before{
|
|
||||||
display: block; position: absolute; top: 0; right: 0;
|
|
||||||
background-color: var(--surface-soft); color: var(--fg);
|
|
||||||
padding: 0 .5em; border-bottom-left-radius: 8px; border: 0;
|
|
||||||
font-size: 80%;
|
|
||||||
}
|
|
||||||
.org-src-container > pre.src-bash:before { content: "bash"; }
|
|
||||||
.org-src-container > pre.src-sh:before { content: "sh"; }
|
|
||||||
.org-src-container > pre.src-shell:before { content: "shell"; }
|
|
||||||
.org-src-container > pre.src-python:before { content: "Python"; }
|
|
||||||
.org-src-container > pre.src-emacs-lisp:before { content: "Emacs Lisp"; }
|
|
||||||
.org-src-container > pre.src-js:before,
|
|
||||||
.org-src-container > pre.src-javascript:before { content: "Javascript"; }
|
|
||||||
.org-src-container > pre.src-typescript:before { content: "Typescript"; }
|
|
||||||
.org-src-container > pre.src-html:before { content: "HTML"; }
|
|
||||||
.org-src-container > pre.src-css:before { content: "CSS"; }
|
|
||||||
.org-src-container > pre.src-c:before { content: "C"; }
|
|
||||||
.org-src-container > pre.src-cpp:before { content: "C++"; }
|
|
||||||
.org-src-container > pre.src-java:before { content: "Java"; }
|
|
||||||
.org-src-container > pre.src-rust:before { content: "Rust"; }
|
|
||||||
.org-src-container > pre.src-R:before { content: "R"; }
|
|
||||||
|
|
||||||
.copy-btn{
|
|
||||||
position: absolute; top: .4em; right: .4em;
|
|
||||||
background-color: var(--code-bg); color: var(--heading);
|
|
||||||
border: 1px solid var(--heading); border-radius: 4px;
|
|
||||||
padding: .2em .6em; font-size: .8rem; cursor: pointer; z-index: 10;
|
|
||||||
transition: background-color .3s;
|
|
||||||
}
|
|
||||||
.copy-btn:hover{ background-color: var(--heading); color: var(--code-bg); }
|
|
||||||
|
|
||||||
pre.src::before{ content: none !important; }
|
|
||||||
pre.src{
|
|
||||||
padding: 1.5em 1em 1em;
|
|
||||||
font-family: "Fira Mono","Courier New",monospace;
|
|
||||||
font-size: .9rem; line-height: 1.4;
|
|
||||||
overflow-x: auto; white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|||||||
648
assets/styles/pages/home-dashboard.css
Normal file
648
assets/styles/pages/home-dashboard.css
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
/* Home dashboard styles. Scoped to .db-* so article pages keep their layout. */
|
||||||
|
|
||||||
|
.db-root {
|
||||||
|
--db-surface: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
|
||||||
|
--db-surface-strong: color-mix(in oklab, var(--bg) 88%, var(--fg) 12%);
|
||||||
|
--db-line: color-mix(in oklab, var(--border) 78%, var(--fg) 22%);
|
||||||
|
--db-text-soft: color-mix(in oklab, var(--fg) 68%, var(--bg) 32%);
|
||||||
|
--db-blue: #2f6fed;
|
||||||
|
--db-green: #15835b;
|
||||||
|
--db-amber: #9d6400;
|
||||||
|
--db-orange: #c55a11;
|
||||||
|
--db-red: #b64c45;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem 0 4rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-root *,
|
||||||
|
.db-root *::before,
|
||||||
|
.db-root *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-kicker {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--db-surface);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__logo {
|
||||||
|
width: clamp(64px, 10vw, 96px);
|
||||||
|
height: clamp(64px, 10vw, 96px);
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
background: var(--bg);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: clamp(2.4rem, 7vw, 5rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__sub {
|
||||||
|
max-width: 58ch;
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: var(--db-text-soft);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-now {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
min-width: min(100%, 230px);
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in oklab, var(--bg) 84%, var(--fg) 16%);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-now__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-now__clock {
|
||||||
|
display: block;
|
||||||
|
margin: 0.2rem 0 0.85rem;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: clamp(1.9rem, 4vw, 2.75rem);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: clamp(1.2rem, 3vw, 1.85rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
background: var(--db-surface);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command__search:focus-within {
|
||||||
|
border-color: var(--db-blue);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--db-blue) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-search-icon {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command__search input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-command__search input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-quicknav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"icon label"
|
||||||
|
"icon meta";
|
||||||
|
column-gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--db-surface);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-left: 4px solid var(--db-blue);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-item:hover,
|
||||||
|
.db-qn-item:focus-visible {
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: color-mix(in oklab, var(--db-blue) 55%, var(--db-line));
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-item.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-item--writing,
|
||||||
|
.db-qn-item--notes { border-left-color: var(--db-blue); }
|
||||||
|
.db-qn-item--career,
|
||||||
|
.db-qn-item--wird { border-left-color: var(--db-green); }
|
||||||
|
.db-qn-item--lima,
|
||||||
|
.db-qn-item--status { border-left-color: var(--db-amber); }
|
||||||
|
.db-qn-item--system,
|
||||||
|
.db-qn-item--zone { border-left-color: var(--db-red); }
|
||||||
|
.db-qn-item--author { border-left-color: var(--db-orange); }
|
||||||
|
|
||||||
|
.db-qn-icon {
|
||||||
|
grid-area: icon;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: var(--fg);
|
||||||
|
background: color-mix(in oklab, var(--fg) 8%, transparent);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-label {
|
||||||
|
grid-area: label;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-meta {
|
||||||
|
grid-area: meta;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel {
|
||||||
|
grid-column: span 4;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--db-surface);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel-stack {
|
||||||
|
grid-column: span 4;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel-stack .db-panel {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel--wide {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel--stats,
|
||||||
|
.db-panel--focus {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel--about {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel__more {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel__more:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel__desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--db-text-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.62rem 0;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__item::before {
|
||||||
|
content: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__dot {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--db-green);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__item a {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__item a:hover {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: start;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.72rem 0;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__item::before {
|
||||||
|
content: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.32rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__top strong {
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__page {
|
||||||
|
color: var(--link);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.db-comment-feed__page:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__top time {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__body p {
|
||||||
|
margin: 0.22rem 0 0;
|
||||||
|
color: var(--db-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-comment-feed__empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat__num {
|
||||||
|
display: block;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: clamp(1.55rem, 4vw, 2.1rem);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat__label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-year-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
background: color-mix(in oklab, var(--border) 75%, var(--bg) 25%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-year-bar__fill {
|
||||||
|
width: 0%;
|
||||||
|
min-width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--db-green);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-year-bar__label {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 0.35rem);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-task-list,
|
||||||
|
.db-link-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-task-list a,
|
||||||
|
.db-link-stack a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
color: var(--fg);
|
||||||
|
background: color-mix(in oklab, var(--bg) 85%, var(--fg) 15%);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-task-list a:hover,
|
||||||
|
.db-link-stack a:hover {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: color-mix(in oklab, var(--link) 45%, var(--db-line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-task-list strong {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-tag {
|
||||||
|
padding: 0.28rem 0.62rem;
|
||||||
|
color: var(--chip-fg);
|
||||||
|
background: var(--chip-bg);
|
||||||
|
border: 1px solid var(--db-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .db-root,
|
||||||
|
:root[data-theme="dark-academia"] .db-root {
|
||||||
|
--db-surface: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
|
||||||
|
--db-surface-strong: color-mix(in oklab, var(--bg) 72%, var(--fg) 28%);
|
||||||
|
--db-line: color-mix(in oklab, var(--border) 80%, var(--fg) 20%);
|
||||||
|
--db-blue: #78a6ff;
|
||||||
|
--db-green: #63c995;
|
||||||
|
--db-amber: #d7a451;
|
||||||
|
--db-orange: #f29a62;
|
||||||
|
--db-red: #e28982;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"] .db-root {
|
||||||
|
--db-blue: #c6b07a;
|
||||||
|
--db-green: #7fb092;
|
||||||
|
--db-amber: #d6b566;
|
||||||
|
--db-orange: #c98b52;
|
||||||
|
--db-red: #c76b71;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.db-quicknav {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel,
|
||||||
|
.db-panel-stack,
|
||||||
|
.db-panel--wide,
|
||||||
|
.db-panel--stats,
|
||||||
|
.db-panel--focus,
|
||||||
|
.db-panel--about {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.db-root {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero,
|
||||||
|
.db-command {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-now {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-quicknav,
|
||||||
|
.db-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel,
|
||||||
|
.db-panel-stack,
|
||||||
|
.db-panel--wide,
|
||||||
|
.db-panel--stats,
|
||||||
|
.db-panel--focus,
|
||||||
|
.db-panel--about {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__item {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-feed__meta {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.db-hero__brand {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__logo {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-qn-item {
|
||||||
|
min-height: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
832
assets/styles/pages/play.css
Normal file
832
assets/styles/pages/play.css
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
.play-root {
|
||||||
|
--play-ink: #2a2118;
|
||||||
|
--play-paper: color-mix(in oklab, var(--surface) 86%, #f4ead2 14%);
|
||||||
|
--play-brass: #9c6b2f;
|
||||||
|
--play-green: #486b57;
|
||||||
|
--play-red: #8a3f3f;
|
||||||
|
--play-blue: #3e5f8a;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 2rem auto 4rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-root * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-root.is-odd {
|
||||||
|
filter: saturate(1.08) contrast(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-hero,
|
||||||
|
.play-console,
|
||||||
|
.play-tool {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--play-paper);
|
||||||
|
box-shadow: 0 18px 45px rgba(35, 26, 16, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: clamp(1.25rem, 4vw, 3rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-kicker {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
color: var(--play-brass);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-hero h1,
|
||||||
|
.play-page-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-lede,
|
||||||
|
.play-page-head p {
|
||||||
|
max-width: 62ch;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-orbit {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
border: 1px dashed color-mix(in oklab, var(--border) 70%, var(--play-brass));
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%),
|
||||||
|
linear-gradient(0deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-orbit::after {
|
||||||
|
content: "PLAY";
|
||||||
|
position: absolute;
|
||||||
|
inset: 35%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-orbit__node {
|
||||||
|
position: absolute;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-orbit__node.is-active,
|
||||||
|
.play-orbit__node:hover {
|
||||||
|
background: var(--play-brass);
|
||||||
|
color: #fff8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-console {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-console__screen {
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.025) 1px, transparent 1px, transparent 7px),
|
||||||
|
color-mix(in oklab, var(--surface) 92%, var(--play-green) 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-console__screen h2 {
|
||||||
|
margin: 0.2rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-whisper,
|
||||||
|
.play-live-note {
|
||||||
|
margin: 0.7rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-console__eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--play-green);
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-console__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-root button,
|
||||||
|
.play-primary,
|
||||||
|
.sigil-form button {
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--play-brass));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-root button:hover,
|
||||||
|
.play-primary:hover,
|
||||||
|
.sigil-form button:hover {
|
||||||
|
background: var(--play-ink);
|
||||||
|
color: #fff8e8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: var(--play-brass);
|
||||||
|
color: #fff8e8;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-grid a {
|
||||||
|
min-height: 135px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 88%, var(--play-paper) 12%);
|
||||||
|
color: var(--fg);
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-grid a:hover,
|
||||||
|
.play-grid a.is-active {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: var(--play-brass);
|
||||||
|
background: color-mix(in oklab, var(--surface) 76%, var(--play-brass) 24%);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-back {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-page-head {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-tool {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-scorebar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(72px, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-card {
|
||||||
|
aspect-ratio: 1.08;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 38%, rgba(246, 216, 137, 0.15), transparent 28%),
|
||||||
|
linear-gradient(135deg, #211812, var(--play-ink));
|
||||||
|
color: #e7c978;
|
||||||
|
font-size: clamp(1.3rem, 4vw, 2.2rem);
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(var(--tilt, 0deg));
|
||||||
|
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-card.is-open,
|
||||||
|
.memory-card.is-matched,
|
||||||
|
.memory-card.is-peeking {
|
||||||
|
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
|
||||||
|
color: var(--fg);
|
||||||
|
transform: rotate(0deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-card.is-matched {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--play-brass) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-canvas,
|
||||||
|
.sigil-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #18130f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poem-tool {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poem-slip {
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poem-slip[data-stamp]:not([data-stamp=""])::after {
|
||||||
|
content: attr(data-stamp);
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border: 2px solid var(--play-red);
|
||||||
|
color: var(--play-red);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookshelf,
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-spine,
|
||||||
|
.timeline-card {
|
||||||
|
touch-action: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-spine {
|
||||||
|
width: 120px;
|
||||||
|
min-height: 230px;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
color: #fff8e8;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-spine:nth-child(3n+1) { background: var(--play-red); }
|
||||||
|
.book-spine:nth-child(3n+2) { background: var(--play-green); }
|
||||||
|
.book-spine:nth-child(3n+3) { background: var(--play-blue); }
|
||||||
|
|
||||||
|
.booklet {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border: 1px dashed var(--play-brass);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 74%, var(--play-brass) 26%);
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-card span {
|
||||||
|
color: var(--play-brass);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-tool {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-wheel {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background:
|
||||||
|
conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
|
||||||
|
color: #fff8e8 !important;
|
||||||
|
font-weight: 900;
|
||||||
|
transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-result li {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tool {
|
||||||
|
background: #15120e;
|
||||||
|
color: #f2e3bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-log {
|
||||||
|
min-height: 310px;
|
||||||
|
max-height: 460px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(242, 227, 189, 0.2);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-form label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-form input,
|
||||||
|
.sigil-form input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-tool {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-scene {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 520px);
|
||||||
|
aspect-ratio: 1.6;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 22%, rgba(244, 210, 113, var(--lamp, 0.18)), transparent 30%),
|
||||||
|
linear-gradient(#27211b 0 62%, #4b3425 62% 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-scene span {
|
||||||
|
position: absolute;
|
||||||
|
left: 42%;
|
||||||
|
bottom: 30%;
|
||||||
|
width: 16%;
|
||||||
|
height: 38%;
|
||||||
|
border-radius: 50% 50% 6px 6px;
|
||||||
|
background: #b88639;
|
||||||
|
box-shadow: 0 0 calc(20px + var(--glow, 0px)) rgba(244, 210, 113, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-time {
|
||||||
|
font-size: clamp(2.4rem, 8vw, 5rem);
|
||||||
|
font-weight: 900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-tool {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 320px) minmax(0, 520px);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigil-canvas {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root {
|
||||||
|
--ash-black: #05070c;
|
||||||
|
--ash-ink: #0d1420;
|
||||||
|
--ash-panel: #121a27;
|
||||||
|
--ash-line: #344154;
|
||||||
|
--ash-gold: #e0a451;
|
||||||
|
--ash-cold: #8bd3ff;
|
||||||
|
--ash-red: #db4f62;
|
||||||
|
--ash-text: #f8efe0;
|
||||||
|
--ash-muted: #a8b4c2;
|
||||||
|
max-width: 1380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root .play-page-head {
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root .play-page-head h1 {
|
||||||
|
color: var(--ash-text);
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root .play-page-head p,
|
||||||
|
.ash-rpg-root .play-back {
|
||||||
|
color: var(--ash-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-shell {
|
||||||
|
position: relative;
|
||||||
|
border-color: rgba(139, 211, 255, 0.22);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
|
||||||
|
var(--ash-black);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
|
||||||
|
0 28px 80px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-stage {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 2px solid var(--ash-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ash-black);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px #05070c,
|
||||||
|
0 0 34px rgba(139, 211, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-canvas {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-dialogue,
|
||||||
|
.rpg-panel {
|
||||||
|
border: 1px solid var(--ash-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
|
||||||
|
var(--ash-panel);
|
||||||
|
color: var(--ash-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-dialogue {
|
||||||
|
min-height: 130px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-dialogue strong,
|
||||||
|
.rpg-list h2 {
|
||||||
|
color: var(--ash-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-dialogue p,
|
||||||
|
.rpg-status {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--ash-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-actions,
|
||||||
|
.rpg-mobile-pad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root button {
|
||||||
|
border-color: rgba(139, 211, 255, 0.28);
|
||||||
|
background: linear-gradient(180deg, #1a2638, #0c121c);
|
||||||
|
color: var(--ash-text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root button:hover:not(:disabled),
|
||||||
|
.ash-rpg-root button:focus-visible {
|
||||||
|
background: linear-gradient(180deg, #31435f, #142236);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-mobile-pad {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-mobile-pad [data-rpg-move="up"],
|
||||||
|
.rpg-mobile-pad [data-rpg-move="down"] {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-mobile-pad [data-rpg-move="left"] {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-mobile-pad [data-rpg-act] {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-mobile-pad [data-rpg-move="right"] {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-stats div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-stats dt,
|
||||||
|
.rpg-list h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ash-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-list ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-list li {
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1px solid rgba(139, 211, 255, 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(5, 7, 12, 0.62);
|
||||||
|
color: var(--ash-text);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-list li.is-done {
|
||||||
|
border-color: rgba(102, 199, 162, 0.42);
|
||||||
|
background: rgba(45, 91, 77, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-list li.is-erased {
|
||||||
|
border-color: rgba(219, 79, 98, 0.32);
|
||||||
|
color: rgba(248, 239, 224, 0.42);
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-choicebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-choicebar button {
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-memory-meter {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--ash-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-memory-meter::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid rgba(139, 211, 255, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #05070c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-memory-meter i {
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
right: auto;
|
||||||
|
bottom: 1px;
|
||||||
|
width: 0;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
|
||||||
|
transition: width 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-name-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-name-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--ash-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-name-form input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(139, 211, 255, 0.24);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: #05070c;
|
||||||
|
color: var(--ash-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root.is-lake-awake .rpg-dialogue,
|
||||||
|
.ash-rpg-root.is-lake-awake .rpg-panel {
|
||||||
|
border-color: rgba(219, 79, 98, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px #05070c,
|
||||||
|
0 0 38px rgba(219, 79, 98, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-root.is-ending .rpg-panel {
|
||||||
|
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.play-hero,
|
||||||
|
.play-console,
|
||||||
|
.recipe-tool,
|
||||||
|
.sigil-tool,
|
||||||
|
.rpg-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-orbit {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-board {
|
||||||
|
grid-template-columns: repeat(3, minmax(64px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-spine {
|
||||||
|
width: calc(50% - 0.4rem);
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-choicebar,
|
||||||
|
.ash-name-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-canvas {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
1888
assets/styles/pages/wird-tracker.css
Normal file
1888
assets/styles/pages/wird-tracker.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,832 +1 @@
|
|||||||
.play-root {
|
@import url("./pages/play.css");
|
||||||
--play-ink: #2a2118;
|
|
||||||
--play-paper: color-mix(in oklab, var(--surface) 86%, #f4ead2 14%);
|
|
||||||
--play-brass: #9c6b2f;
|
|
||||||
--play-green: #486b57;
|
|
||||||
--play-red: #8a3f3f;
|
|
||||||
--play-blue: #3e5f8a;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 2rem auto 4rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-root * {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-root.is-odd {
|
|
||||||
filter: saturate(1.08) contrast(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-hero,
|
|
||||||
.play-console,
|
|
||||||
.play-tool {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--play-paper);
|
|
||||||
box-shadow: 0 18px 45px rgba(35, 26, 16, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
padding: clamp(1.25rem, 4vw, 3rem);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-kicker {
|
|
||||||
margin: 0 0 0.45rem;
|
|
||||||
color: var(--play-brass);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-hero h1,
|
|
||||||
.play-page-head h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-lede,
|
|
||||||
.play-page-head p {
|
|
||||||
max-width: 62ch;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
min-height: 300px;
|
|
||||||
border: 1px dashed color-mix(in oklab, var(--border) 70%, var(--play-brass));
|
|
||||||
border-radius: 50%;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%),
|
|
||||||
linear-gradient(0deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit::after {
|
|
||||||
content: "PLAY";
|
|
||||||
position: absolute;
|
|
||||||
inset: 35%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit__node {
|
|
||||||
position: absolute;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit__node.is-active,
|
|
||||||
.play-orbit__node:hover {
|
|
||||||
background: var(--play-brass);
|
|
||||||
color: #fff8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-console {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-console__screen {
|
|
||||||
min-height: 190px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.025) 1px, transparent 1px, transparent 7px),
|
|
||||||
color-mix(in oklab, var(--surface) 92%, var(--play-green) 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-console__screen h2 {
|
|
||||||
margin: 0.2rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-whisper,
|
|
||||||
.play-live-note {
|
|
||||||
margin: 0.7rem 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-console__eyebrow {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--play-green);
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-console__controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-root button,
|
|
||||||
.play-primary,
|
|
||||||
.sigil-form button {
|
|
||||||
min-height: 42px;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--play-brass));
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.55rem 0.85rem;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-root button:hover,
|
|
||||||
.play-primary:hover,
|
|
||||||
.sigil-form button:hover {
|
|
||||||
background: var(--play-ink);
|
|
||||||
color: #fff8e8;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-primary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
background: var(--play-brass);
|
|
||||||
color: #fff8e8;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-grid a {
|
|
||||||
min-height: 135px;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 88%, var(--play-paper) 12%);
|
|
||||||
color: var(--fg);
|
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-grid a:hover,
|
|
||||||
.play-grid a.is-active {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
border-color: var(--play-brass);
|
|
||||||
background: color-mix(in oklab, var(--surface) 76%, var(--play-brass) 24%);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-grid span {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-grid strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 2rem;
|
|
||||||
color: var(--heading);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-back {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-page-head {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-tool {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-scorebar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-board {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(72px, 1fr));
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-card {
|
|
||||||
aspect-ratio: 1.08;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 38%, rgba(246, 216, 137, 0.15), transparent 28%),
|
|
||||||
linear-gradient(135deg, #211812, var(--play-ink));
|
|
||||||
color: #e7c978;
|
|
||||||
font-size: clamp(1.3rem, 4vw, 2.2rem);
|
|
||||||
font-weight: 900;
|
|
||||||
transform: rotate(var(--tilt, 0deg));
|
|
||||||
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-card.is-open,
|
|
||||||
.memory-card.is-matched,
|
|
||||||
.memory-card.is-peeking {
|
|
||||||
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
|
|
||||||
color: var(--fg);
|
|
||||||
transform: rotate(0deg) translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-card.is-matched {
|
|
||||||
outline: 2px solid color-mix(in oklab, var(--play-brass) 70%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-canvas,
|
|
||||||
.sigil-canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 70vh;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #18130f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poem-tool {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poem-slip {
|
|
||||||
min-height: 220px;
|
|
||||||
padding: 1.2rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poem-slip[data-stamp]:not([data-stamp=""])::after {
|
|
||||||
content: attr(data-stamp);
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border: 2px solid var(--play-red);
|
|
||||||
color: var(--play-red);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
transform: rotate(-4deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookshelf,
|
|
||||||
.timeline-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-spine,
|
|
||||||
.timeline-card {
|
|
||||||
touch-action: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 7px;
|
|
||||||
padding: 0.8rem;
|
|
||||||
background: var(--surface);
|
|
||||||
cursor: grab;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-spine {
|
|
||||||
width: 120px;
|
|
||||||
min-height: 230px;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
text-orientation: mixed;
|
|
||||||
color: #fff8e8;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-spine:nth-child(3n+1) { background: var(--play-red); }
|
|
||||||
.book-spine:nth-child(3n+2) { background: var(--play-green); }
|
|
||||||
.book-spine:nth-child(3n+3) { background: var(--play-blue); }
|
|
||||||
|
|
||||||
.booklet {
|
|
||||||
flex: 1 1 220px;
|
|
||||||
min-height: 110px;
|
|
||||||
padding: 0.9rem;
|
|
||||||
border: 1px dashed var(--play-brass);
|
|
||||||
border-radius: 7px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 74%, var(--play-brass) 26%);
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-list {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 5rem 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-card span {
|
|
||||||
color: var(--play-brass);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tool {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-wheel {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background:
|
|
||||||
conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
|
|
||||||
color: #fff8e8 !important;
|
|
||||||
font-weight: 900;
|
|
||||||
transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-result {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-result li {
|
|
||||||
padding: 0.85rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 7px;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-tool {
|
|
||||||
background: #15120e;
|
|
||||||
color: #f2e3bd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-log {
|
|
||||||
min-height: 310px;
|
|
||||||
max-height: 460px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid rgba(242, 227, 189, 0.2);
|
|
||||||
border-radius: 7px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-form label {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-form input,
|
|
||||||
.sigil-form input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.65rem;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.study-tool {
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.study-scene {
|
|
||||||
position: relative;
|
|
||||||
width: min(100%, 520px);
|
|
||||||
aspect-ratio: 1.6;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 22%, rgba(244, 210, 113, var(--lamp, 0.18)), transparent 30%),
|
|
||||||
linear-gradient(#27211b 0 62%, #4b3425 62% 100%);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.study-scene span {
|
|
||||||
position: absolute;
|
|
||||||
left: 42%;
|
|
||||||
bottom: 30%;
|
|
||||||
width: 16%;
|
|
||||||
height: 38%;
|
|
||||||
border-radius: 50% 50% 6px 6px;
|
|
||||||
background: #b88639;
|
|
||||||
box-shadow: 0 0 calc(20px + var(--glow, 0px)) rgba(244, 210, 113, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.study-time {
|
|
||||||
font-size: clamp(2.4rem, 8vw, 5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sigil-tool {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(240px, 320px) minmax(0, 520px);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sigil-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sigil-form label {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sigil-canvas {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root {
|
|
||||||
--ash-black: #05070c;
|
|
||||||
--ash-ink: #0d1420;
|
|
||||||
--ash-panel: #121a27;
|
|
||||||
--ash-line: #344154;
|
|
||||||
--ash-gold: #e0a451;
|
|
||||||
--ash-cold: #8bd3ff;
|
|
||||||
--ash-red: #db4f62;
|
|
||||||
--ash-text: #f8efe0;
|
|
||||||
--ash-muted: #a8b4c2;
|
|
||||||
max-width: 1380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head {
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head h1 {
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head p,
|
|
||||||
.ash-rpg-root .play-back {
|
|
||||||
color: var(--ash-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-shell {
|
|
||||||
position: relative;
|
|
||||||
border-color: rgba(139, 211, 255, 0.22);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
|
|
||||||
var(--ash-black);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
|
|
||||||
0 28px 80px rgba(0, 0, 0, 0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-shell::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stage {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
border: 2px solid var(--ash-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--ash-black);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 4px #05070c,
|
|
||||||
0 0 34px rgba(139, 211, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-canvas {
|
|
||||||
min-height: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue,
|
|
||||||
.rpg-panel {
|
|
||||||
border: 1px solid var(--ash-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
|
|
||||||
var(--ash-panel);
|
|
||||||
color: var(--ash-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue {
|
|
||||||
min-height: 130px;
|
|
||||||
padding: 1rem;
|
|
||||||
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue strong,
|
|
||||||
.rpg-list h2 {
|
|
||||||
color: var(--ash-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue p,
|
|
||||||
.rpg-status {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-actions,
|
|
||||||
.rpg-mobile-pad {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button {
|
|
||||||
border-color: rgba(139, 211, 255, 0.28);
|
|
||||||
background: linear-gradient(180deg, #1a2638, #0c121c);
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button:hover:not(:disabled),
|
|
||||||
.ash-rpg-root button:focus-visible {
|
|
||||||
background: linear-gradient(180deg, #31435f, #142236);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="up"],
|
|
||||||
.rpg-mobile-pad [data-rpg-move="down"] {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="left"] {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-act] {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="right"] {
|
|
||||||
grid-column: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding-bottom: 0.45rem;
|
|
||||||
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats dt,
|
|
||||||
.rpg-list h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats dd {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin: 0.45rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li {
|
|
||||||
padding: 0.55rem;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.16);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(5, 7, 12, 0.62);
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li.is-done {
|
|
||||||
border-color: rgba(102, 199, 162, 0.42);
|
|
||||||
background: rgba(45, 91, 77, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li.is-erased {
|
|
||||||
border-color: rgba(219, 79, 98, 0.32);
|
|
||||||
color: rgba(248, 239, 224, 0.42);
|
|
||||||
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar button {
|
|
||||||
min-height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 10px;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.24);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #05070c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter i {
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
right: auto;
|
|
||||||
bottom: 1px;
|
|
||||||
width: 0;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
|
|
||||||
transition: width 220ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form label {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.24);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.65rem;
|
|
||||||
background: #05070c;
|
|
||||||
color: var(--ash-text);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-lake-awake .rpg-dialogue,
|
|
||||||
.ash-rpg-root.is-lake-awake .rpg-panel {
|
|
||||||
border-color: rgba(219, 79, 98, 0.36);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 4px #05070c,
|
|
||||||
0 0 38px rgba(219, 79, 98, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-ending .rpg-panel {
|
|
||||||
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.play-hero,
|
|
||||||
.play-console,
|
|
||||||
.recipe-tool,
|
|
||||||
.sigil-tool,
|
|
||||||
.rpg-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit {
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-board {
|
|
||||||
grid-template-columns: repeat(3, minmax(64px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-spine {
|
|
||||||
width: calc(50% - 0.4rem);
|
|
||||||
min-height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar,
|
|
||||||
.ash-name-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-canvas {
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,875 +1,5 @@
|
|||||||
:root{
|
@import url("./themes/tokens.css");
|
||||||
--gutter: 2rem;
|
@import url("./base/elements.css");
|
||||||
--margin: 420px;
|
@import url("./layout/site-shell.css");
|
||||||
--body-pad: 1rem;
|
@import url("./components/notes-board.css");
|
||||||
--content: clamp(
|
@import url("./components/mermaid.css");
|
||||||
var(--content-min),
|
|
||||||
calc(100vi - 2*var(--body-pad) - 2*(var(--margin) + var(--gutter))),
|
|
||||||
var(--content-max)
|
|
||||||
);
|
|
||||||
--content-min: 60ch;
|
|
||||||
--content-max: 880px;
|
|
||||||
--bleed: 48px;
|
|
||||||
--fullwidth-cap: 860px;
|
|
||||||
--bg: #faf9f6;
|
|
||||||
--fg: #000000;
|
|
||||||
--link: #1a0dab;
|
|
||||||
--heading: #004c99;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-soft: #f3f1eb;
|
|
||||||
--accent: #2563eb;
|
|
||||||
--code-bg: #f0f0f0;
|
|
||||||
--active-toc: #cacaca;
|
|
||||||
--note-color: #555;
|
|
||||||
--note-bg: transparent;
|
|
||||||
--border: #d7d7d7;
|
|
||||||
--chip-bg: #f0f0f0;
|
|
||||||
--chip-fg: #444;
|
|
||||||
--muted: #666;
|
|
||||||
--bg-color: var(--surface);
|
|
||||||
--border-color: var(--border);
|
|
||||||
--border-muted: var(--border);
|
|
||||||
--text-muted: var(--muted);
|
|
||||||
--accent-bg: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
|
||||||
|
|
||||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
|
||||||
--kb-bg-completed: #e6f4ea;
|
|
||||||
--kb-bg-manager-review: #fff4e5;
|
|
||||||
--kb-bg-in-progress: #e8f0fe;
|
|
||||||
--kb-bg-not-started: #f1f3f5;
|
|
||||||
--kb-bg-comments: #fdecea;
|
|
||||||
|
|
||||||
--kb-column-border: rgba(0,0,0,0.06);
|
|
||||||
--kb-card-bg: #ffffff;
|
|
||||||
--kb-card-text: #1f2933;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"]{
|
|
||||||
--bg: #0f1115;
|
|
||||||
--fg: #e6e6e6;
|
|
||||||
--link: #8ab4ff;
|
|
||||||
--heading: #9ecbff;
|
|
||||||
--surface: #151820;
|
|
||||||
--surface-soft: #1b202a;
|
|
||||||
--accent: #8ab4ff;
|
|
||||||
--code-bg: #1a1d24;
|
|
||||||
|
|
||||||
--note-color: #c2c7cf;
|
|
||||||
--note-bg: transparent;
|
|
||||||
|
|
||||||
--border: #2a2f3a;
|
|
||||||
--chip-bg: #1f2330;
|
|
||||||
--chip-fg: #cfd3da;
|
|
||||||
--muted: #a9b0bb;
|
|
||||||
--active-toc: #313131;
|
|
||||||
|
|
||||||
--kb-bg-completed: #1e3a2b;
|
|
||||||
--kb-bg-manager-review: #3a2f1e;
|
|
||||||
--kb-bg-in-progress: #1f2a44;
|
|
||||||
--kb-bg-not-started: #2a2d31;
|
|
||||||
--kb-bg-comments: #3b1f24;
|
|
||||||
|
|
||||||
--kb-column-border: rgba(255,255,255,0.08);
|
|
||||||
--kb-card-bg: #16181c;
|
|
||||||
--kb-card-text: #e5e7eb;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"]{
|
|
||||||
color-scheme: dark;
|
|
||||||
--bg: #11100d;
|
|
||||||
--fg: #f7ecd5;
|
|
||||||
--link: #d8b86f;
|
|
||||||
--heading: #e8cf91;
|
|
||||||
--surface: #1d1a16;
|
|
||||||
--surface-soft: #242019;
|
|
||||||
--accent: #b89145;
|
|
||||||
--code-bg: #191814;
|
|
||||||
--active-toc: rgba(184, 145, 69, 0.18);
|
|
||||||
--note-color: #d9c9aa;
|
|
||||||
--note-bg: rgba(243, 234, 215, 0.05);
|
|
||||||
--border: rgba(217, 190, 124, 0.32);
|
|
||||||
--chip-bg: rgba(184, 145, 69, 0.14);
|
|
||||||
--chip-fg: #f0d89a;
|
|
||||||
--muted: #b6a681;
|
|
||||||
--bg-color: var(--surface);
|
|
||||||
--border-color: var(--border);
|
|
||||||
--border-muted: rgba(217, 190, 124, 0.24);
|
|
||||||
--text-muted: var(--muted);
|
|
||||||
--accent-bg: rgba(184, 145, 69, 0.16);
|
|
||||||
|
|
||||||
--kb-bg-completed: rgba(68, 106, 87, 0.24);
|
|
||||||
--kb-bg-manager-review: rgba(184, 145, 69, 0.2);
|
|
||||||
--kb-bg-in-progress: rgba(82, 93, 117, 0.22);
|
|
||||||
--kb-bg-not-started: rgba(243, 234, 215, 0.08);
|
|
||||||
--kb-bg-comments: rgba(125, 47, 54, 0.24);
|
|
||||||
|
|
||||||
--kb-column-border: rgba(217, 190, 124, 0.2);
|
|
||||||
--kb-card-bg: #181510;
|
|
||||||
--kb-card-text: #f3ead7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto';
|
|
||||||
src: url('../fonts/NotoSans-Regular.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
html, body{
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 18% 12%, var(--theme-glow-wine, transparent), transparent 28%),
|
|
||||||
radial-gradient(circle at 84% 18%, var(--theme-glow-green, transparent), transparent 24%),
|
|
||||||
linear-gradient(135deg, var(--theme-bg-start, var(--bg)) 0%, var(--theme-bg-mid, var(--bg)) 42%, var(--theme-bg-end, var(--bg)) 100%);
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 16px;
|
|
||||||
transition: background-color .3s, color .3s;
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Noto', system-ui, sans-serif;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] body {
|
|
||||||
--theme-glow-wine: rgba(125, 47, 54, 0.24);
|
|
||||||
--theme-glow-green: rgba(68, 106, 87, 0.24);
|
|
||||||
--theme-bg-start: #0f0e0b;
|
|
||||||
--theme-bg-mid: #17140f;
|
|
||||||
--theme-bg-end: #211b14;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.16;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
|
|
||||||
background-size: 34px 34px, 34px 34px;
|
|
||||||
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: color-mix(in oklab, var(--accent) 24%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3{
|
|
||||||
color: var(--heading);
|
|
||||||
line-height: 1.18;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 2.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 2.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--link);
|
|
||||||
text-decoration: none;
|
|
||||||
text-underline-offset: 0.18em;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-header{
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.85rem;
|
|
||||||
padding: 0.7rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
|
|
||||||
box-shadow: 0 12px 32px color-mix(in oklab, #000 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] .banner-header,
|
|
||||||
:root[data-theme="dark-academia"] .org-src-container,
|
|
||||||
:root[data-theme="dark-academia"] #notes-form,
|
|
||||||
:root[data-theme="dark-academia"] .note {
|
|
||||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] h1,
|
|
||||||
:root[data-theme="dark-academia"] h2,
|
|
||||||
:root[data-theme="dark-academia"] h3 {
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 800;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand__text {
|
|
||||||
font-size: 0.98rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-logo{
|
|
||||||
height: 42px;
|
|
||||||
width: 42px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav a {
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav a:hover,
|
|
||||||
.site-nav a:focus-visible {
|
|
||||||
color: var(--fg);
|
|
||||||
background: color-mix(in oklab, var(--fg) 7%, transparent);
|
|
||||||
text-decoration: none;
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.4rem;
|
|
||||||
flex: 0 1 23rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-search {
|
|
||||||
display: block;
|
|
||||||
flex: 1 1 13rem;
|
|
||||||
min-width: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#preamble.status{
|
|
||||||
max-width: var(--content);
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding-left: var(--body-pad);
|
|
||||||
padding-right: var(--body-pad);
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#preamble .banner-header,
|
|
||||||
#preamble #updated{
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
footer{
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-top: 3rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: center;
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content.content{
|
|
||||||
max-width: var(--content);
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
padding-left: var(--body-pad);
|
|
||||||
padding-right: var(--body-pad);
|
|
||||||
box-sizing: content-box;
|
|
||||||
position: relative;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#content .figure,
|
|
||||||
#content img:not(.fullwidth){
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--heading);
|
|
||||||
background: color-mix(in oklab, var(--surface-soft) 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
border-left: 4px solid var(--heading);
|
|
||||||
background: color-mix(in oklab, var(--surface-soft) 72%, transparent);
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
color: color-mix(in oklab, var(--fg) 84%, var(--muted) 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
margin: 1rem 0 1.5rem 1.5rem;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li::before {
|
|
||||||
content: "•";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
color: var(--heading);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
counter-reset: list-counter;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol li {
|
|
||||||
counter-increment: list-counter;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 1.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol li::before {
|
|
||||||
content: counter(list-counter) ".";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
color: var(--heading);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flow-root;
|
|
||||||
}
|
|
||||||
|
|
||||||
li::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li ul,
|
|
||||||
li ol {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li ul li::before {
|
|
||||||
content: "–";
|
|
||||||
font-weight: normal;
|
|
||||||
color: var(--note-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
li ol li::before {
|
|
||||||
font-weight: normal;
|
|
||||||
color: var(--note-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.epigraph {
|
|
||||||
margin: 2rem auto;
|
|
||||||
max-width: 80%;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.epigraph blockquote {
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-left: 4px solid var(--heading);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
color: var(--fg);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.epigraph blockquote footer {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--note-color);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.epigraph blockquote cite {
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.epigraph blockquote::before,
|
|
||||||
.epigraph blockquote::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.epigraph blockquote{
|
|
||||||
border-left-color: var(--heading);
|
|
||||||
background-color: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.epigraph blockquote footer{ color: var(--muted); }
|
|
||||||
|
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
|
||||||
|
|
||||||
.bp-x{ right:72px; }
|
|
||||||
|
|
||||||
.bp-next,.bp-prev{ right:8px; }
|
|
||||||
.bp-prev{ left:8px; }
|
|
||||||
|
|
||||||
.bp-wrap{ transition: opacity .18s ease; }
|
|
||||||
.bp-wrap.bp-fadeout{ opacity: 0; }
|
|
||||||
|
|
||||||
.bp-controls{
|
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
|
||||||
padding-right: calc(env(safe-area-inset-right, 0) + 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body.no-sidenotes {
|
|
||||||
--body-pad: 0;
|
|
||||||
--margin: 0;
|
|
||||||
--gutter: 0;
|
|
||||||
|
|
||||||
--content-min: 80ch;
|
|
||||||
--content-max: 1200px;
|
|
||||||
|
|
||||||
--content: clamp(
|
|
||||||
var(--content-min),
|
|
||||||
100vi,
|
|
||||||
var(--content-max)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
NOTES BOARD
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
#notes-tools {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.65rem;
|
|
||||||
margin: 1.25rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-tools label {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-author-filter {
|
|
||||||
min-width: min(220px, 100%);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-author-filter:focus {
|
|
||||||
outline: 2px solid color-mix(in oklab, var(--accent) 45%, transparent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-wall {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin: 2.5rem 0;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
NOTE CARD
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
.note {
|
|
||||||
position: relative;
|
|
||||||
padding: 1rem 1rem 0.9rem;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow:
|
|
||||||
0 2px 6px rgba(0, 0, 0, 0.08),
|
|
||||||
0 12px 24px rgba(0, 0, 0, 0.06);
|
|
||||||
transform: rotate(var(--note-tilt, 0deg));
|
|
||||||
transition:
|
|
||||||
transform 0.15s ease,
|
|
||||||
box-shadow 0.15s ease,
|
|
||||||
background-color 0.2s ease,
|
|
||||||
border-color 0.2s ease;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* subtle randomness */
|
|
||||||
.note:nth-child(3n) { --note-tilt: -0.6deg; }
|
|
||||||
.note:nth-child(4n) { --note-tilt: 0.4deg; }
|
|
||||||
.note:nth-child(5n) { --note-tilt: -0.3deg; }
|
|
||||||
|
|
||||||
.note:hover {
|
|
||||||
transform: rotate(0deg) translateY(-4px);
|
|
||||||
box-shadow:
|
|
||||||
0 6px 14px rgba(0, 0, 0, 0.12),
|
|
||||||
0 18px 36px rgba(0, 0, 0, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
PIN
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
.note::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0.6rem;
|
|
||||||
left: 50%;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: #c33;
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
META
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
.note-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-author {
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-meta time {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
CONTENT
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
.note-content {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
MOBILE TWEAKS
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
#notes-wall {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-tools {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note::before {
|
|
||||||
left: 0.75rem;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* =========================
|
|
||||||
NOTES FORM
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
#notes-form-wrapper {
|
|
||||||
margin-top: 3rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-form {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 1.25rem 1.25rem 1.1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow:
|
|
||||||
0 2px 6px rgba(0, 0, 0, 0.08),
|
|
||||||
0 14px 30px rgba(0, 0, 0, 0.06);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* pin */
|
|
||||||
#notes-form::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0.6rem;
|
|
||||||
left: 50%;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: #c33;
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
LABELS
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
#notes-form label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
INPUTS
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
#notes-form input,
|
|
||||||
#notes-form textarea {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
padding: 0.3rem 0;
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-form textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 4.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* focus state */
|
|
||||||
#notes-form input:focus,
|
|
||||||
#notes-form textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* placeholder */
|
|
||||||
#notes-form ::placeholder {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
SUBMIT BUTTON
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
#notes-form button {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.35rem 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
background: var(--fg);
|
|
||||||
color: var(--bg);
|
|
||||||
cursor: pointer;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-form button:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
#notes-form button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
MOBILE
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
#notes-form {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mermaid container for zooming and panning */
|
|
||||||
.mermaid-container {
|
|
||||||
overflow: auto;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 26rem;
|
|
||||||
max-height: 800px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
|
|
||||||
position: relative;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make SVG scale properly */
|
|
||||||
.mermaid-container .mermaid svg {
|
|
||||||
transform-origin: top left; /* needed for scaling */
|
|
||||||
transition: transform 0.2s ease; /* smooth zoom */
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-container--site-map {
|
|
||||||
width: min(96vw, 1400px);
|
|
||||||
height: min(82vh, 980px);
|
|
||||||
max-height: none;
|
|
||||||
margin-left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-container--site-map .mermaid {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: max-content;
|
|
||||||
padding: 3rem 2rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-container--site-map .mermaid svg {
|
|
||||||
width: auto;
|
|
||||||
max-width: none;
|
|
||||||
min-width: 1100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.mermaid-container--site-map {
|
|
||||||
width: 100%;
|
|
||||||
height: 78vh;
|
|
||||||
margin-left: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-container--site-map .mermaid svg {
|
|
||||||
min-width: 900px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zoom buttons */
|
|
||||||
.mermaid-zoom-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-zoom-controls button {
|
|
||||||
padding: 5px 10px;
|
|
||||||
margin-left: 5px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-zoom-controls button:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--surface);
|
|
||||||
}
|
|
||||||
|
|||||||
110
assets/styles/themes/tokens.css
Normal file
110
assets/styles/themes/tokens.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
:root{
|
||||||
|
--gutter: 2rem;
|
||||||
|
--margin: 420px;
|
||||||
|
--body-pad: 1rem;
|
||||||
|
--content: clamp(
|
||||||
|
var(--content-min),
|
||||||
|
calc(100vi - 2*var(--body-pad) - 2*(var(--margin) + var(--gutter))),
|
||||||
|
var(--content-max)
|
||||||
|
);
|
||||||
|
--content-min: 60ch;
|
||||||
|
--content-max: 880px;
|
||||||
|
--bleed: 48px;
|
||||||
|
--fullwidth-cap: 860px;
|
||||||
|
--bg: #faf9f6;
|
||||||
|
--fg: #000000;
|
||||||
|
--link: #1a0dab;
|
||||||
|
--heading: #004c99;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f3f1eb;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--code-bg: #f0f0f0;
|
||||||
|
--active-toc: #cacaca;
|
||||||
|
--note-color: #555;
|
||||||
|
--note-bg: transparent;
|
||||||
|
--border: #d7d7d7;
|
||||||
|
--chip-bg: #f0f0f0;
|
||||||
|
--chip-fg: #444;
|
||||||
|
--muted: #666;
|
||||||
|
--bg-color: var(--surface);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--border-muted: var(--border);
|
||||||
|
--text-muted: var(--muted);
|
||||||
|
--accent-bg: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
||||||
|
|
||||||
|
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--kb-bg-completed: #e6f4ea;
|
||||||
|
--kb-bg-manager-review: #fff4e5;
|
||||||
|
--kb-bg-in-progress: #e8f0fe;
|
||||||
|
--kb-bg-not-started: #f1f3f5;
|
||||||
|
--kb-bg-comments: #fdecea;
|
||||||
|
|
||||||
|
--kb-column-border: rgba(0,0,0,0.06);
|
||||||
|
--kb-card-bg: #ffffff;
|
||||||
|
--kb-card-text: #1f2933;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--bg: #0f1115;
|
||||||
|
--fg: #e6e6e6;
|
||||||
|
--link: #8ab4ff;
|
||||||
|
--heading: #9ecbff;
|
||||||
|
--surface: #151820;
|
||||||
|
--surface-soft: #1b202a;
|
||||||
|
--accent: #8ab4ff;
|
||||||
|
--code-bg: #1a1d24;
|
||||||
|
|
||||||
|
--note-color: #c2c7cf;
|
||||||
|
--note-bg: transparent;
|
||||||
|
|
||||||
|
--border: #2a2f3a;
|
||||||
|
--chip-bg: #1f2330;
|
||||||
|
--chip-fg: #cfd3da;
|
||||||
|
--muted: #a9b0bb;
|
||||||
|
--active-toc: #313131;
|
||||||
|
|
||||||
|
--kb-bg-completed: #1e3a2b;
|
||||||
|
--kb-bg-manager-review: #3a2f1e;
|
||||||
|
--kb-bg-in-progress: #1f2a44;
|
||||||
|
--kb-bg-not-started: #2a2d31;
|
||||||
|
--kb-bg-comments: #3b1f24;
|
||||||
|
|
||||||
|
--kb-column-border: rgba(255,255,255,0.08);
|
||||||
|
--kb-card-bg: #16181c;
|
||||||
|
--kb-card-text: #e5e7eb;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark-academia"]{
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #11100d;
|
||||||
|
--fg: #f7ecd5;
|
||||||
|
--link: #d8b86f;
|
||||||
|
--heading: #e8cf91;
|
||||||
|
--surface: #1d1a16;
|
||||||
|
--surface-soft: #242019;
|
||||||
|
--accent: #b89145;
|
||||||
|
--code-bg: #191814;
|
||||||
|
--active-toc: rgba(184, 145, 69, 0.18);
|
||||||
|
--note-color: #d9c9aa;
|
||||||
|
--note-bg: rgba(243, 234, 215, 0.05);
|
||||||
|
--border: rgba(217, 190, 124, 0.32);
|
||||||
|
--chip-bg: rgba(184, 145, 69, 0.14);
|
||||||
|
--chip-fg: #f0d89a;
|
||||||
|
--muted: #b6a681;
|
||||||
|
--bg-color: var(--surface);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--border-muted: rgba(217, 190, 124, 0.24);
|
||||||
|
--text-muted: var(--muted);
|
||||||
|
--accent-bg: rgba(184, 145, 69, 0.16);
|
||||||
|
|
||||||
|
--kb-bg-completed: rgba(68, 106, 87, 0.24);
|
||||||
|
--kb-bg-manager-review: rgba(184, 145, 69, 0.2);
|
||||||
|
--kb-bg-in-progress: rgba(82, 93, 117, 0.22);
|
||||||
|
--kb-bg-not-started: rgba(243, 234, 215, 0.08);
|
||||||
|
--kb-bg-comments: rgba(125, 47, 54, 0.24);
|
||||||
|
|
||||||
|
--kb-column-border: rgba(217, 190, 124, 0.2);
|
||||||
|
--kb-card-bg: #181510;
|
||||||
|
--kb-card-text: #f3ead7;
|
||||||
|
}
|
||||||
134
assets/styles/utilities/responsive.css
Normal file
134
assets/styles/utilities/responsive.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
@media (max-width: 600px){
|
||||||
|
.banner-header {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-brand {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updated {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.no-sidenotes {
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-move-panel {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root:not([data-theme]){
|
||||||
|
--bg: #0f1115;
|
||||||
|
--fg: #e6e6e6;
|
||||||
|
--link: #8ab4ff;
|
||||||
|
--heading: #9ecbff;
|
||||||
|
--code-bg: #1a1d24;
|
||||||
|
|
||||||
|
--note-color: #c2c7cf;
|
||||||
|
--note-bg: transparent;
|
||||||
|
|
||||||
|
--border: #2a2f3a;
|
||||||
|
--chip-bg: #1f2330;
|
||||||
|
--chip-fg: #cfd3da;
|
||||||
|
--muted: #a9b0bb;
|
||||||
|
--surface: #151820;
|
||||||
|
--surface-soft: #1b202a;
|
||||||
|
--accent: #8ab4ff;
|
||||||
|
--bg-color: var(--surface);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--border-muted: var(--border);
|
||||||
|
--text-muted: var(--muted);
|
||||||
|
--accent-bg: color-mix(in oklab, var(--accent) 12%, var(--surface));
|
||||||
|
}
|
||||||
|
.countdown-wrap {
|
||||||
|
color: var(--fg, #ddd);
|
||||||
|
}
|
||||||
|
time.countdown {
|
||||||
|
color: var(--accent, #7abfff);
|
||||||
|
background: color-mix(in srgb, var(--accent, #7abfff) 15%, transparent);
|
||||||
|
box-shadow: 0 0 6px rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
time.countdown.expired {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1250px){
|
||||||
|
:root {
|
||||||
|
--content-min: 0px;
|
||||||
|
--content: min(var(--content-max), calc(100vi - 2 * var(--body-pad)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#preamble.status{
|
||||||
|
padding-right: var(--body-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content.content{
|
||||||
|
padding-right: var(--body-pad);
|
||||||
|
}
|
||||||
|
.sidenote,
|
||||||
|
.marginnote{
|
||||||
|
float: none;
|
||||||
|
clear: both;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-left: 3px solid rgba(0,0,0,.08);
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.fullwidth{
|
||||||
|
max-width: calc(100vw - 2 * var(--body-pad));
|
||||||
|
}
|
||||||
|
.sidenote, .marginnote{
|
||||||
|
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
|
||||||
|
}
|
||||||
|
.sidenote .mn-img,
|
||||||
|
.marginnote .mn-img{
|
||||||
|
border-color: color-mix(in oklab, var(--fg) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-of-contents{
|
||||||
|
float: none;
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--bg) 96%, var(--fg) 4%);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
8
assets/styles/vendor/bigger-picture.min.css
vendored
Normal file
8
assets/styles/vendor/bigger-picture.min.css
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Minified by jsDelivr using clean-css v5.3.2.
|
||||||
|
* Original file: /npm/bigger-picture@1.1.19/dist/bigger-picture.css
|
||||||
|
*
|
||||||
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
*/
|
||||||
|
@keyframes bp-fadein{from{opacity:.01}to{opacity:1}}@keyframes bp-bar{from{transform:translateX(-100%)}to{transform:translateX(0)}}@keyframes bp-o{from{transform:rotate(0)}to{transform:rotate(360deg)}}.bp-wrap{top:0;left:0;width:100%;height:100%;position:fixed;z-index:999;contain:strict;touch-action:none;-webkit-tap-highlight-color:transparent}.bp-wrap>div:first-child{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.75);animation:bp-fadein .48s cubic-bezier(.215,.61,.355,1)}.bp-vid audio{position:absolute;left:14px;width:calc(100% - 28px);bottom:14px;height:50px}.bp-inner{top:0;left:0;width:100%;height:100%;position:absolute;display:flex}.bp-html{display:contents}.bp-html>:first-child{margin:auto}.bp-img-wrap{top:0;left:0;width:100%;height:100%;position:absolute;contain:strict}.bp-img-wrap .bp-canzoom{cursor:zoom-in}.bp-img-wrap .bp-drag{cursor:grabbing}.bp-close{contain:layout size}.bp-img{position:absolute;top:50%;left:50%;user-select:none;background-size:100% 100%}.bp-img div,.bp-img img{position:absolute;top:0;left:0;width:100%;height:100%}.bp-img .bp-o{display:none}.bp-zoomed .bp-img:not(.bp-drag){cursor:grab}.bp-zoomed .bp-cap{opacity:0;animation:none!important}.bp-zoomed.bp-small .bp-controls{opacity:0}.bp-zoomed.bp-small .bp-controls button{pointer-events:none}.bp-controls{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;text-align:left;transition:opacity .3s;animation:bp-fadein .3s}.bp-controls button{pointer-events:auto;cursor:pointer;position:absolute;border:0;background:rgba(0,0,0,.15);opacity:.9;transition:all .1s;contain:content}.bp-controls button:hover{background-color:rgba(0,0,0,.2);opacity:1}.bp-controls svg{fill:#fff}.bp-count{position:absolute;color:rgba(255,255,255,.9);line-height:1;margin:16px;height:50px;width:100px}.bp-next,.bp-prev{top:50%;right:0;margin-top:-32px;height:64px;width:58px;border-radius:3px 0 0 3px}.bp-next:hover:before,.bp-prev:hover:before{transform:translateX(-2px)}.bp-next:before,.bp-prev:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z'/%3E%3C/svg%3E");position:absolute;left:7px;top:9px;width:46px;transition:all .2s}.bp-prev{right:auto;left:0;transform:scalex(-1)}.bp-x{top:0;right:0;height:55px;width:58px;border-radius:0 0 0 3px}.bp-x:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23fff'%3E%3Cpath d='M24 10l-2-2-6 6-6-6-2 2 6 6-6 6 2 2 6-6 6 6 2-2-6-6z'/%3E%3C/svg%3E");position:absolute;width:37px;top:8px;right:10px}.bp-if,.bp-vid{position:relative;margin:auto;background:#000;background-size:100% 100%}.bp-if div,.bp-if iframe,.bp-if video,.bp-vid div,.bp-vid iframe,.bp-vid video{top:0;left:0;width:100%;height:100%;position:absolute;border:0}.bp-load{display:flex;background-size:100% 100%;overflow:hidden;z-index:1}.bp-bar{position:absolute;top:0;left:0;height:3px;width:100%;transform:translateX(-100%);background:rgba(255,255,255,.9);border-radius:0 3px 3px 0;animation:bp-bar 4s both}.bp-o,.bp-o:after{border-radius:50%;width:90px;height:90px}.bp-o{margin:auto;border:10px solid rgba(255,255,255,.2);border-left-color:rgba(255,255,255,.9);animation:bp-o 1s infinite linear}.bp-cap{position:absolute;bottom:2%;background:rgba(9,9,9,.8);color:rgba(255,255,255,.9);border-radius:4px;max-width:95%;line-height:1.3;padding:.6em 1.2em;left:50%;transform:translateX(-50%);width:fit-content;width:-moz-fit-content;display:table;transition:opacity .3s;animation:bp-fadein .2s}.bp-cap a{color:inherit}.bp-inline{position:absolute}.bp-lock{overflow-y:hidden}.bp-lock body{overflow:scroll}.bp-noclose .bp-x{display:none}.bp-noclose:not(.bp-zoomed){touch-action:pan-y}.bp-noclose:not(.bp-zoomed) .bp-img-wrap{cursor:zoom-in}@media (prefers-reduced-motion){.bp-wrap *{animation-duration:0s!important}}@media (max-width:500px){.bp-x{height:47px;width:47px}.bp-x:before{width:34px;top:6px;right:6px}.bp-next,.bp-prev{margin-top:-27px;height:54px;width:45px}.bp-next:before,.bp-prev:before{top:7px;left:2px;width:43px}.bp-o,.bp-o:after{border-width:6px;width:60px;height:60px}.bp-count{margin:12px 10px}}
|
||||||
|
/*# sourceMappingURL=/sm/15e96278e1e731ce40eef8d6284cefc81b81dda67c3a0aa386ec893f183bd57f.map */
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,648 +1 @@
|
|||||||
/* Home dashboard styles. Scoped to .db-* so article pages keep their layout. */
|
@import url("./pages/home-dashboard.css");
|
||||||
|
|
||||||
.db-root {
|
|
||||||
--db-surface: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
|
|
||||||
--db-surface-strong: color-mix(in oklab, var(--bg) 88%, var(--fg) 12%);
|
|
||||||
--db-line: color-mix(in oklab, var(--border) 78%, var(--fg) 22%);
|
|
||||||
--db-text-soft: color-mix(in oklab, var(--fg) 68%, var(--bg) 32%);
|
|
||||||
--db-blue: #2f6fed;
|
|
||||||
--db-green: #15835b;
|
|
||||||
--db-amber: #9d6400;
|
|
||||||
--db-orange: #c55a11;
|
|
||||||
--db-red: #b64c45;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.5rem 0 4rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-root *,
|
|
||||||
.db-root *::before,
|
|
||||||
.db-root *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-kicker {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: var(--db-surface);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__logo {
|
|
||||||
width: clamp(64px, 10vw, 96px);
|
|
||||||
height: clamp(64px, 10vw, 96px);
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
background: var(--bg);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: clamp(2.4rem, 7vw, 5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__sub {
|
|
||||||
max-width: 58ch;
|
|
||||||
margin: 0.55rem 0 0;
|
|
||||||
color: var(--db-text-soft);
|
|
||||||
font-size: 0.98rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-now {
|
|
||||||
display: grid;
|
|
||||||
align-content: center;
|
|
||||||
min-width: min(100%, 230px);
|
|
||||||
padding: 1rem;
|
|
||||||
background: color-mix(in oklab, var(--bg) 84%, var(--fg) 16%);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-now__label {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-now__clock {
|
|
||||||
display: block;
|
|
||||||
margin: 0.2rem 0 0.85rem;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: clamp(1.9rem, 4vw, 2.75rem);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command__title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: clamp(1.2rem, 3vw, 1.85rem);
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command__search {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 0 0.85rem;
|
|
||||||
background: var(--db-surface);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command__search:focus-within {
|
|
||||||
border-color: var(--db-blue);
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--db-blue) 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-search-icon {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command__search input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-command__search input::placeholder {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-quicknav {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
grid-template-areas:
|
|
||||||
"icon label"
|
|
||||||
"icon meta";
|
|
||||||
column-gap: 0.7rem;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 76px;
|
|
||||||
padding: 0.9rem;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--db-surface);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-left: 4px solid var(--db-blue);
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-item:hover,
|
|
||||||
.db-qn-item:focus-visible {
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
border-color: color-mix(in oklab, var(--db-blue) 55%, var(--db-line));
|
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-item.is-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-item--writing,
|
|
||||||
.db-qn-item--notes { border-left-color: var(--db-blue); }
|
|
||||||
.db-qn-item--career,
|
|
||||||
.db-qn-item--wird { border-left-color: var(--db-green); }
|
|
||||||
.db-qn-item--lima,
|
|
||||||
.db-qn-item--status { border-left-color: var(--db-amber); }
|
|
||||||
.db-qn-item--system,
|
|
||||||
.db-qn-item--zone { border-left-color: var(--db-red); }
|
|
||||||
.db-qn-item--author { border-left-color: var(--db-orange); }
|
|
||||||
|
|
||||||
.db-qn-icon {
|
|
||||||
grid-area: icon;
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
color: var(--fg);
|
|
||||||
background: color-mix(in oklab, var(--fg) 8%, transparent);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-label {
|
|
||||||
grid-area: label;
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 850;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-meta {
|
|
||||||
grid-area: meta;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel {
|
|
||||||
grid-column: span 4;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--db-surface);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel-stack {
|
|
||||||
grid-column: span 4;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel-stack .db-panel {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel--wide {
|
|
||||||
grid-column: span 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel--stats,
|
|
||||||
.db-panel--focus {
|
|
||||||
grid-column: span 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel--about {
|
|
||||||
grid-column: span 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel__title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel__more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--link);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel__more:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel__desc {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--db-text-soft);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.15rem;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-items: baseline;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.62rem 0;
|
|
||||||
margin: 0;
|
|
||||||
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__item::before {
|
|
||||||
content: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__dot {
|
|
||||||
width: 0.48rem;
|
|
||||||
height: 0.48rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
background: var(--db-green);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__item a {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 700;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__item a:hover {
|
|
||||||
color: var(--link);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__meta {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed {
|
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 0.65rem;
|
|
||||||
align-items: start;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.72rem 0;
|
|
||||||
margin: 0;
|
|
||||||
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__item::before {
|
|
||||||
content: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__top {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.32rem;
|
|
||||||
align-items: baseline;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__top strong {
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__page {
|
|
||||||
color: var(--link);
|
|
||||||
font-weight: 800;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.db-comment-feed__page:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__top time {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__body p {
|
|
||||||
margin: 0.22rem 0 0;
|
|
||||||
color: var(--db-text-soft);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-comment-feed__empty {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-stat {
|
|
||||||
min-height: 92px;
|
|
||||||
padding: 0.8rem;
|
|
||||||
background: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-stat__num {
|
|
||||||
display: block;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: clamp(1.55rem, 4vw, 2.1rem);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-stat__label {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-year-bar {
|
|
||||||
position: relative;
|
|
||||||
height: 8px;
|
|
||||||
background: color-mix(in oklab, var(--border) 75%, var(--bg) 25%);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-year-bar__fill {
|
|
||||||
width: 0%;
|
|
||||||
min-width: 6px;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--db-green);
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: width 0.45s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-year-bar__label {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% + 0.35rem);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 850;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-task-list,
|
|
||||||
.db-link-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-task-list a,
|
|
||||||
.db-link-stack a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
color: var(--fg);
|
|
||||||
background: color-mix(in oklab, var(--bg) 85%, var(--fg) 15%);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-task-list a:hover,
|
|
||||||
.db-link-stack a:hover {
|
|
||||||
color: var(--link);
|
|
||||||
text-decoration: none;
|
|
||||||
border-color: color-mix(in oklab, var(--link) 45%, var(--db-line));
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-task-list strong {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-tag {
|
|
||||||
padding: 0.28rem 0.62rem;
|
|
||||||
color: var(--chip-fg);
|
|
||||||
background: var(--chip-bg);
|
|
||||||
border: 1px solid var(--db-line);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] .db-root,
|
|
||||||
:root[data-theme="dark-academia"] .db-root {
|
|
||||||
--db-surface: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
|
|
||||||
--db-surface-strong: color-mix(in oklab, var(--bg) 72%, var(--fg) 28%);
|
|
||||||
--db-line: color-mix(in oklab, var(--border) 80%, var(--fg) 20%);
|
|
||||||
--db-blue: #78a6ff;
|
|
||||||
--db-green: #63c995;
|
|
||||||
--db-amber: #d7a451;
|
|
||||||
--db-orange: #f29a62;
|
|
||||||
--db-red: #e28982;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark-academia"] .db-root {
|
|
||||||
--db-blue: #c6b07a;
|
|
||||||
--db-green: #7fb092;
|
|
||||||
--db-amber: #d6b566;
|
|
||||||
--db-orange: #c98b52;
|
|
||||||
--db-red: #c76b71;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.db-quicknav {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel,
|
|
||||||
.db-panel-stack,
|
|
||||||
.db-panel--wide,
|
|
||||||
.db-panel--stats,
|
|
||||||
.db-panel--focus,
|
|
||||||
.db-panel--about {
|
|
||||||
grid-column: span 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.db-root {
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero,
|
|
||||||
.db-command {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__brand {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-now {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-quicknav,
|
|
||||||
.db-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-panel,
|
|
||||||
.db-panel-stack,
|
|
||||||
.db-panel--wide,
|
|
||||||
.db-panel--stats,
|
|
||||||
.db-panel--focus,
|
|
||||||
.db-panel--about {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__item {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-feed__meta {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
|
||||||
.db-hero__brand {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-hero__logo {
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-qn-item {
|
|
||||||
min-height: 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,24 +9,12 @@ Meet the characters!
|
|||||||
|
|
||||||
** lima
|
** lima
|
||||||
|
|
||||||
[[../../../assets/avatars/lima.jpg]]
|
|
||||||
|
|
||||||
** aphy
|
** aphy
|
||||||
|
|
||||||
[[../../../assets/avatars/aphy.jpeg]]
|
|
||||||
|
|
||||||
** z
|
** z
|
||||||
|
|
||||||
[[../../../assets/avatars/z.jpeg]]
|
|
||||||
|
|
||||||
** future z
|
** future z
|
||||||
|
|
||||||
[[../../../assets/avatars/future-z.jpeg]]
|
|
||||||
|
|
||||||
** young z
|
** young z
|
||||||
|
|
||||||
[[../../../assets/avatars/young-z.jpeg]]
|
|
||||||
|
|
||||||
** sensei chi
|
** sensei chi
|
||||||
|
|
||||||
[[../../../assets/avatars/sensei-chi.jpeg]]
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
|||||||
- [[file:2025/08-august/benefits-of-reading.org][Benefits of Reading]] @@html:<span class="post-date">14-08-2025 23:36</span>@@ @@html:<a href="/tags/reading.html"><span class="post-tag">reading</span></a>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
- [[file:2025/08-august/benefits-of-reading.org][Benefits of Reading]] @@html:<span class="post-date">14-08-2025 23:36</span>@@ @@html:<a href="/tags/reading.html"><span class="post-tag">reading</span></a>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
||||||
- [[file:2025/08-august/hilberts.hotel.org][Hilbert's Hotel]] @@html:<span class="post-date">11-08-2025 18:39</span>@@ @@html:<a href="/tags/maths.html"><span class="post-tag">maths</span></a>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
- [[file:2025/08-august/hilberts.hotel.org][Hilbert's Hotel]] @@html:<span class="post-date">11-08-2025 18:39</span>@@ @@html:<a href="/tags/maths.html"><span class="post-tag">maths</span></a>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
||||||
- [[file:2025/08-august/spending-the-whole-day-on-this-website.org][09-08-2025: Website Changes]] @@html:<span class="post-date">09-08-2025 00:00</span>@@ @@html:<a href="/tags/emacs.html"><span class="post-tag">emacs</span></a>@@ @@html:<a href="/tags/website.html"><span class="post-tag">website</span></a>@@
|
- [[file:2025/08-august/spending-the-whole-day-on-this-website.org][09-08-2025: Website Changes]] @@html:<span class="post-date">09-08-2025 00:00</span>@@ @@html:<a href="/tags/emacs.html"><span class="post-tag">emacs</span></a>@@ @@html:<a href="/tags/website.html"><span class="post-tag">website</span></a>@@
|
||||||
- [[file:2025/08-august/wacom-with-arch.org][Wacom With Arch]] @@html:<span class="post-date">08-08-2025 00:00</span>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
|
||||||
- [[file:2025/08-august/what-do-i-want-to-do-with-emacs.org][What Do I Want To Do With Emacs]] @@html:<span class="post-date">08-08-2025 00:00</span>@@ @@html:<a href="/tags/emacs.html"><span class="post-tag">emacs</span></a>@@ @@html:<a href="/tags/review.html"><span class="post-tag">review</span></a>@@
|
- [[file:2025/08-august/what-do-i-want-to-do-with-emacs.org][What Do I Want To Do With Emacs]] @@html:<span class="post-date">08-08-2025 00:00</span>@@ @@html:<a href="/tags/emacs.html"><span class="post-tag">emacs</span></a>@@ @@html:<a href="/tags/review.html"><span class="post-tag">review</span></a>@@
|
||||||
|
- [[file:2025/08-august/wacom-with-arch.org][Wacom With Arch]] @@html:<span class="post-date">08-08-2025 00:00</span>@@ @@html:<a href="/tags/insights.html"><span class="post-tag">insights</span></a>@@
|
||||||
- [[file:2025/08-august/zettelkasten.org][Zettelkasten Method]] @@html:<span class="post-date">07-08-2025 00:00</span>@@ @@html:<a href="/tags/education.html"><span class="post-tag">education</span></a>@@
|
- [[file:2025/08-august/zettelkasten.org][Zettelkasten Method]] @@html:<span class="post-date">07-08-2025 00:00</span>@@ @@html:<a href="/tags/education.html"><span class="post-tag">education</span></a>@@
|
||||||
- [[file:blogs-intro.org][Blogs Introduction]] @@html:<span class="post-date">06-08-2025 00:00</span>@@ @@html:<a href="/tags/introduction.html"><span class="post-tag">introduction</span></a>@@
|
- [[file:blogs-intro.org][Blogs Introduction]] @@html:<span class="post-date">06-08-2025 00:00</span>@@ @@html:<a href="/tags/introduction.html"><span class="post-tag">introduction</span></a>@@
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#+TITLE: Books List
|
|
||||||
#+OPTIONS: toc:nil num:nil
|
|
||||||
|
|
||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
|
||||||
|
|
||||||
* Book Notes:
|
|
||||||
- [[file:clean-code/clean-code-notes.org][Clean Code Notes]] @@html:<span class="post-date">10-08-2025 19:45</span>@@ @@html:<a href="/tags/books.html"><span class="post-tag">books</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
#+TITLE: Clean Code Notes
|
|
||||||
#+OPTIONS: num:nil tags:t toc:t
|
|
||||||
#+DATE: <2025-08-10 Sun 19:45>
|
|
||||||
#+FILETAGS: :books:notes:
|
|
||||||
|
|
||||||
/Author: Robert C. Martin/
|
|
||||||
|
|
||||||
* Chapter 1: Clean Code
|
|
||||||
|
|
||||||
Referenced Items:
|
|
||||||
- Implementation Patterns, Kent Beck, Addison-Wesley, 2007.
|
|
||||||
- Literate Programming, Donald E. Knuth, Center for the Study of Language and Information, Leland Stanford Junior University, 1992.
|
|
||||||
|
|
||||||
Principles mentioned:
|
|
||||||
Single Responsibility Principle (SRP), the Open Closed Principle (OCP), and the Dependency Inversion Principle (DIP)
|
|
||||||
|
|
||||||
* Chapter 2: Meaningful Names
|
|
||||||
|
|
||||||
** Use intention revealing names:
|
|
||||||
|
|
||||||
Names should reveal intent, there is no revelation in naming an integer ~d~, intending it stands for days. Instead, you should use the following names:
|
|
||||||
#+begin_src java
|
|
||||||
int elapsedTimeInDays;
|
|
||||||
int daysSinceCreation;
|
|
||||||
int daysSinceModification;
|
|
||||||
int fileAgeInDays;
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
** Avoid disinformation
|
|
||||||
|
|
||||||
Don't postfix the word 'list' to the name 'accounts' unless it's actually a list. This is because the reader will /assume/ the data type of accountsList is indeed a list, instead choose a name like ~accountsGroup~.
|
|
||||||
|
|
||||||
** Make Meaningful Distinctions
|
|
||||||
|
|
||||||
While it is possible to name by being disinformative, it is also possible to name being non informative. Consider:
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
public static void copyChars(char a1[], char a2[]) {
|
|
||||||
for (int i = 0; i < a1.length; i++) {
|
|
||||||
a2[i] = a1[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
What on earth does ~a[1]~ and ~a[2]~ even stand for? We are better off using names like source and destination (due to the function's intent of copying the array).
|
|
||||||
|
|
||||||
Furthermore, noise words are redundant. We should never use the word ~variable~ when naming a variable, or ~table~ when naming a table.
|
|
||||||
** Use Pronouncable Names
|
|
||||||
|
|
||||||
This is quite straightforward. Do not use a name like ~genymdhms~ to refer to generation date, year, month, day, hour, minute,
|
|
||||||
and second. Instead use ~generationTimeStamp~.
|
|
||||||
|
|
||||||
** Use Searchable Names
|
|
||||||
|
|
||||||
In modern IDE's, it is still quite difficult to search for single-lettered variables. The writer states a personal preference of using single-letter names only as local variables and inside short methods. The following principle is given:
|
|
||||||
|
|
||||||
/The length of a name should correspond to the size of its scope/
|
|
||||||
|
|
||||||
** Avoid Encodings
|
|
||||||
|
|
||||||
Don't prefix variables with letters like m_ as was done in the past. Do not type encode as well, an example of this is: ~PhoneNumber phoneString;~ we can see the reader being misled into thinking the phone number is a String.
|
|
||||||
|
|
||||||
** Avoid Mental Mappings
|
|
||||||
|
|
||||||
Clarity is king, don't use a name for a variable that only you know what it stands for. For example: using the letter r as the lower-cased version of the url with the host and scheme
|
|
||||||
removed. That's being smart, not professional.
|
|
||||||
|
|
||||||
** Class Names
|
|
||||||
|
|
||||||
{{{epigraph_single(Classes and objects should have noun or noun phrase names like Customer\, WikiPage\,
|
|
||||||
Account\, and AddressParser. Avoid words like Manager\, Processor\, Data\, or Info in the name
|
|
||||||
of a class. A class name should not be a verb)}}}
|
|
||||||
|
|
||||||
** Method Names
|
|
||||||
|
|
||||||
Methods should have verb or verb phrase names.
|
|
||||||
|
|
||||||
** Don't be cute/Don't use puns
|
|
||||||
|
|
||||||
Do not use names that are only understandable to people whom you share jokes etc with. Furthermore, do not use colloquialism and slang in names.
|
|
||||||
- Example: ~HandGrenade~ instead of ~DeleteItems~
|
|
||||||
- Example: ~whack()~ instead of ~kill()~
|
|
||||||
|
|
||||||
** Pick one word per concept
|
|
||||||
|
|
||||||
If you have multiple choices for naming a concept, use one and stick with it. For instance if your options are fetch, get and retrieve, use one and stick with it throughout.
|
|
||||||
|
|
||||||
** Solution Domain Names and Problem Domain Names
|
|
||||||
|
|
||||||
Where possible use solution domain names, as the people that are going to be reading the code are programmers. Therefore, do not shy away from using CS terms, algorithm names, math names and so forth.
|
|
||||||
|
|
||||||
However when it is not possible to use solution domain names (in other words, when there is no "programmer-eese" then use the name from the problem domain. The other programmers can ask the domain expert for clarification. If the code is more to do with the problem domain concepts, then the names should be drawn from them.
|
|
||||||
|
|
||||||
** Add Meaningful Context
|
|
||||||
|
|
||||||
Enclose names with well-named classes, functions, or namespaces. When all else fails, then prefix with something that provides more context.
|
|
||||||
|
|
||||||
** Don't add gratuitous context
|
|
||||||
|
|
||||||
Shorter names are better than longer ones, generally. This is so long as the context and intent is clear. Don't add redundant or irrelevant additions to the name in the for the sake of 'context'.
|
|
||||||
|
|
||||||
* Chapter 3: Functions
|
|
||||||
|
|
||||||
** Functions should be small
|
|
||||||
|
|
||||||
Functions should be extremely short—ideally just a few lines, so they remain easy to understand and maintain.
|
|
||||||
|
|
||||||
Avoid deeply nested blocks; keep indentation shallow (1–2 levels), often replacing blocks with descriptive function calls.
|
|
||||||
|
|
||||||
A small function tells a concise, self-contained story, making it easier for readers to follow the program’s intent.
|
|
||||||
|
|
||||||
The smaller the function, the more descriptive and accurate its name can be, improving self-documentation.
|
|
||||||
|
|
||||||
Large functions hide complexity and mix abstraction levels, making errors and duplication more likely.
|
|
||||||
|
|
||||||
** Do One Thing & One Level of Abstraction
|
|
||||||
|
|
||||||
A function should do exactly one conceptual task, and all its statements should exist at the same abstraction level.
|
|
||||||
|
|
||||||
Mixing details (like string concatenation) with high-level actions (like rendering a page) causes confusion.
|
|
||||||
|
|
||||||
The Stepdown Rule: organise functions so they read like a top down narrative, each calling the next abstraction level.
|
|
||||||
|
|
||||||
If you can extract a subfunction with a name that isn’t a restatement, the original function is doing too much.
|
|
||||||
|
|
||||||
Functions that “do one thing” cannot be logically split into sections such as “initialize,” “process,” “finalize.”
|
|
||||||
|
|
||||||
** Switch Statements
|
|
||||||
|
|
||||||
Switch statements naturally violate “do one thing” by handling multiple cases; they also grow in size over time.
|
|
||||||
|
|
||||||
They break the Single Responsibility Principle (multiple reasons to change) and Open-Closed Principle (must change for new cases).
|
|
||||||
|
|
||||||
Preferred approach: hide switch statements inside a factory and dispatch behavior polymorphically through an interface.
|
|
||||||
|
|
||||||
Allow only one visible switch in your system, used solely for object creation, then encapsulate it.
|
|
||||||
|
|
||||||
This removes duplication and keeps high-level code unaware of concrete type distinctions.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
public abstract class Employee {
|
|
||||||
public abstract boolean isPayday();
|
|
||||||
public abstract Money calculatePay();
|
|
||||||
public abstract void deliverPay(Money pay);
|
|
||||||
}
|
|
||||||
-----------------
|
|
||||||
public interface EmployeeFactory {
|
|
||||||
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
|
|
||||||
}
|
|
||||||
-----------------
|
|
||||||
public class EmployeeFactoryImpl implements EmployeeFactory {
|
|
||||||
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
|
|
||||||
switch (r.type) {
|
|
||||||
case COMMISSIONED:
|
|
||||||
return new CommissionedEmployee(r) ;
|
|
||||||
case HOURLY:
|
|
||||||
return new HourlyEmployee(r);
|
|
||||||
case SALARIED:
|
|
||||||
return new SalariedEmploye(r);
|
|
||||||
default:
|
|
||||||
throw new InvalidEmployeeType(r.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
** Use Descriptive Names
|
|
||||||
|
|
||||||
A function’s name should clearly state its purpose. Long, descriptive names beat short, cryptic ones.
|
|
||||||
|
|
||||||
Consistent naming patterns (shared verbs/nouns) help code read like a coherent story and aid predictability.
|
|
||||||
|
|
||||||
Descriptive names reduce the need for comments and improve comprehension without external documentation.
|
|
||||||
|
|
||||||
Renaming functions can reveal design improvements, so try multiple options until the best emerges.
|
|
||||||
|
|
||||||
IDE refactoring tools make renaming safe, encouraging experimentation.
|
|
||||||
|
|
||||||
** Function Arguments
|
|
||||||
|
|
||||||
{{{epigraph_single(The ideal number of arguments for a function is zero (niladic). Next comes one (monadic)\, followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification—and then shouldn’t be used anyway.)}}}
|
|
||||||
|
|
||||||
Fewer arguments = better; aim for 0–2, avoid more than 3 unless absolutely necessary.
|
|
||||||
|
|
||||||
Flag arguments (booleans) are a red flag—they imply the function does multiple things.
|
|
||||||
|
|
||||||
Group related parameters into objects (e.g., ~Point~ for ~x~ and ~y~) to reduce argument count and improve clarity.
|
|
||||||
|
|
||||||
Output arguments are confusing—prefer returning values or mutating the owning object’s state.
|
|
||||||
|
|
||||||
Match function/argument names in verb–noun or keyword style (e.g., ~writeField(name)~, ~assertExpectedEqualsActual~).
|
|
||||||
|
|
||||||
** Have No Side Effects
|
|
||||||
|
|
||||||
A function should do only what its name promises. Hidden state changes are misleading and dangerous.
|
|
||||||
|
|
||||||
Side effects create temporal coupling, meaning the function must be called in a certain sequence to be safe.
|
|
||||||
|
|
||||||
If unavoidable, make side effects explicit in the name (e.g., ~checkPasswordAndInitializeSession~).
|
|
||||||
|
|
||||||
Clear separation of command and query functions avoids ambiguity in meaning and intent.
|
|
||||||
|
|
||||||
Functions that modify state and return information often cause confusion and should be split.
|
|
||||||
|
|
||||||
** Error Handling
|
|
||||||
|
|
||||||
Error handling is a single responsibility—separate it from normal logic to keep both paths clear.
|
|
||||||
|
|
||||||
Prefer exceptions over error codes to avoid cluttering the happy path and to reduce dependency magnets.
|
|
||||||
|
|
||||||
Extract try/catch bodies into their own functions for cleaner structure.
|
|
||||||
|
|
||||||
See below:
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
public void delete(Page page) {
|
|
||||||
try {
|
|
||||||
deletePageAndAllReferences(page);
|
|
||||||
}
|
|
||||||
catch (Exception e) {
|
|
||||||
logError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deletePageAndAllReferences(Page page) throws Exception {
|
|
||||||
deletePage(page);
|
|
||||||
registry.deleteReference(page.name);
|
|
||||||
configKeys.deleteKey(page.name.makeKey());
|
|
||||||
}
|
|
||||||
private void logError(Exception e) {
|
|
||||||
logger.log(e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
Keep functions small enough that occasional multiple return or break statements are acceptable.
|
|
||||||
|
|
||||||
Avoid duplication in error handling, and follow the DRY principle to ensure changes occur in one place.
|
|
||||||
|
|
||||||
|
|
||||||
* Chapter 4: Comments
|
|
||||||
|
|
||||||
Comments are a necessary evil—they exist because code fails to express intent clearly.
|
|
||||||
|
|
||||||
Outdated comments are dangerous; they can mislead more than help.
|
|
||||||
|
|
||||||
Strive to write code that explains itself; comments should be minimised.
|
|
||||||
|
|
||||||
Truth is always in the code, not in the comments.
|
|
||||||
|
|
||||||
** Comments Do Not Make Up for Bad Code
|
|
||||||
|
|
||||||
Don’t use comments to excuse messy, unclear code. Clean the code instead.
|
|
||||||
|
|
||||||
Clear, expressive code with few comments > cluttered code with many comments.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
// Check to see if the employee is eligible for full benefits
|
|
||||||
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))
|
|
||||||
|
|
||||||
// Better:
|
|
||||||
if (employee.isEligibleForFullBenefits())
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
** Good Comments
|
|
||||||
|
|
||||||
Only write them when unavoidable. Such as in the following instances:
|
|
||||||
|
|
||||||
*** Legal Comments
|
|
||||||
|
|
||||||
Sometimes required for copyright/licensing.
|
|
||||||
|
|
||||||
Keep them short; refer to standard licenses rather than embedding full legal text.
|
|
||||||
|
|
||||||
*** Informative Comments
|
|
||||||
|
|
||||||
Explain return values, formats, or patterns.
|
|
||||||
|
|
||||||
Prefer naming/structuring code to make such comments unnecessary.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
// format matched kk:mm:ss EEE, MMM dd, yyyy
|
|
||||||
Pattern timeMatcher = Pattern.compile("\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*");
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
|
|
||||||
*** Explanation of Intent
|
|
||||||
|
|
||||||
Describe why a certain approach was chosen.
|
|
||||||
|
|
||||||
Helps future maintainers understand reasoning behind code.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
public int compareTo(Object o)
|
|
||||||
{
|
|
||||||
if(o instanceof WikiPagePath)
|
|
||||||
{ WikiPagePath p = (WikiPagePath) o;
|
|
||||||
String compressedName = StringUtil.join(names, "");
|
|
||||||
String compressedArgumentName = StringUtil.join(p.names, "");
|
|
||||||
return compressedName.compareTo(compressedArgumentName);
|
|
||||||
}
|
|
||||||
return 1; // we are greater because we are the right type.
|
|
||||||
}
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Clarification
|
|
||||||
|
|
||||||
Translate obscure values into readable terms.
|
|
||||||
|
|
||||||
Useful when working with unchangeable APIs/libraries, but risky if incorrect.
|
|
||||||
|
|
||||||
*** Warning of Consequences
|
|
||||||
|
|
||||||
Alert others about performance, thread-safety, or side effects.
|
|
||||||
|
|
||||||
For example, in code you can say:
|
|
||||||
|
|
||||||
~// SimpleDateFormat is not thread safe, so create each instance independently.~
|
|
||||||
|
|
||||||
*** \TODO\ Comments
|
|
||||||
|
|
||||||
Mark incomplete work or planned improvements.
|
|
||||||
|
|
||||||
Should be reviewed regularly; not an excuse for bad code.
|
|
||||||
|
|
||||||
*** Amplification
|
|
||||||
|
|
||||||
Highlight the importance of seemingly small details.
|
|
||||||
|
|
||||||
~// the trim is real important. It removes starting spaces...~
|
|
||||||
|
|
||||||
*** Javadocs in Public APIs
|
|
||||||
|
|
||||||
Public APIs should have clear documentation.
|
|
||||||
|
|
||||||
Javadocs can also mislead. Keep them accurate and up-to-date.
|
|
||||||
|
|
||||||
** Bad Comments
|
|
||||||
- Don't place a comment just because you feel like it.
|
|
||||||
- Remove redundant comments.
|
|
||||||
- Avoid misleading comments.
|
|
||||||
- Don't mandate everything (not every function needs a Javadoc).
|
|
||||||
- No need for journal comments, we have source control.
|
|
||||||
- Remove noise comments.
|
|
||||||
|
|
||||||
*** Don’t Use a Comment When You Can Use a Function or Variable
|
|
||||||
|
|
||||||
Replace explanatory comments with expressive variable or function names.
|
|
||||||
|
|
||||||
Refactor code to remove comment redundancy.
|
|
||||||
|
|
||||||
*** Position Markers
|
|
||||||
|
|
||||||
Avoid decorative banners like ~// Actions ///////////////////////~, they add clutter.
|
|
||||||
|
|
||||||
Use sparingly and only for meaningful grouping.
|
|
||||||
|
|
||||||
Overuse makes them blend into background noise.
|
|
||||||
|
|
||||||
*** Closing Brace Comments
|
|
||||||
|
|
||||||
Comments on closing braces (} // while) are unnecessary for small, well structured functions.
|
|
||||||
|
|
||||||
Prefer short, clear functions over brace markers.
|
|
||||||
|
|
||||||
*** Attributions and Bylines
|
|
||||||
|
|
||||||
Don’t add personal tags like ~/* Added by Rick */~, use version control for authorship history.
|
|
||||||
|
|
||||||
Such comments become outdated and irrelevant over time.
|
|
||||||
|
|
||||||
*** Commented Out Code
|
|
||||||
|
|
||||||
Never keep old code commented out; delete it and rely on version control history.
|
|
||||||
|
|
||||||
Commented-out code adds clutter and confuses future maintainers.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
// Old cruft that should be deleted:
|
|
||||||
//hdrPos = bytePos;
|
|
||||||
//dataPos = bytePos;
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** HTML Comments
|
|
||||||
|
|
||||||
Avoid HTML markup inside code comments, it makes them harder to read in the editor.
|
|
||||||
|
|
||||||
Let documentation tools (like Javadoc) handle formatting.
|
|
||||||
|
|
||||||
*** Nonlocal Information
|
|
||||||
|
|
||||||
Comments should describe nearby code only, not unrelated parts of the system.
|
|
||||||
|
|
||||||
Avoid embedding global/system details that the function can’t control.
|
|
||||||
|
|
||||||
*** Too Much Information
|
|
||||||
|
|
||||||
Avoid long, unnecessary historical or technical explanations.
|
|
||||||
|
|
||||||
Keep only relevant context (e.g., “RFC 2045” reference is fine, not the full spec).
|
|
||||||
|
|
||||||
*** Inobvious Connection
|
|
||||||
|
|
||||||
Ensure the relationship between comment and code is clear.
|
|
||||||
|
|
||||||
Don’t make readers guess what part of the code the comment refers to.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
|
|
||||||
// plus filter bytes ... but which part is “filter”?
|
|
||||||
this.pngBytes = new byte[((this.width + 1) * this.height * 3) + 200];
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Function Headers
|
|
||||||
|
|
||||||
Short, single purpose functions with good names don’t need header comments.
|
|
||||||
|
|
||||||
Let the function name explain the purpose.
|
|
||||||
|
|
||||||
*** Javadocs in Nonpublic Code
|
|
||||||
|
|
||||||
Javadocs are useful for public APIs, but excessive formality in internal code is just noise.
|
|
||||||
|
|
||||||
Internal methods should be self explanatory without full doc comments.
|
|
||||||
* Chapter 5: Formatting
|
|
||||||
** Vertical Formatting
|
|
||||||
- Vertical openness (blank lines) separates concepts and improves readability.
|
|
||||||
- Too much density makes code look like a muddle and harder to scan.
|
|
||||||
|
|
||||||
*** Vertical Density
|
|
||||||
- Tightly related lines should appear vertically dense.
|
|
||||||
- Avoid useless comments that interrupt association.
|
|
||||||
- Example (bad):
|
|
||||||
#+BEGIN_SRC java
|
|
||||||
public class ReporterConfig {
|
|
||||||
/**
|
|
||||||
,* The class name of the reporter listener
|
|
||||||
,*/
|
|
||||||
private String m_className;
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
- Example (better):
|
|
||||||
#+BEGIN_SRC java
|
|
||||||
public class ReporterConfig {
|
|
||||||
private String m_className;
|
|
||||||
private List<Property> m_properties = new ArrayList<>();
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
*** Vertical Distance
|
|
||||||
- Related concepts should be kept close together to reduce scrolling and searching.
|
|
||||||
- Local variables → as close to use as possible, usually at top of function.
|
|
||||||
- Control variables → declared inside loop headers.
|
|
||||||
- Instance variables → declared at the top of class (common Java convention).
|
|
||||||
|
|
||||||
#+BEGIN_SRC java
|
|
||||||
for (Test each : tests) {
|
|
||||||
count += each.countTestCases();
|
|
||||||
}
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
- Dependent functions: caller above callee for natural top-down reading.
|
|
||||||
#+BEGIN_SRC java
|
|
||||||
public Response makeResponse(...) {
|
|
||||||
String pageName = getPageNameOrDefault(request, "FrontPage");
|
|
||||||
loadPage(pageName, context);
|
|
||||||
return makePageResponse(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getPageNameOrDefault(Request request, String defaultPageName) { ... }
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
*** Conceptual Affinity
|
|
||||||
- Group functions with similar naming or shared purpose.
|
|
||||||
- Example (JUnit assert methods):
|
|
||||||
#+BEGIN_SRC java
|
|
||||||
static public void assertTrue(String message, boolean condition) { ... }
|
|
||||||
static public void assertTrue(boolean condition) { ... }
|
|
||||||
static public void assertFalse(String message, boolean condition) { ... }
|
|
||||||
static public void assertFalse(boolean condition) { ... }
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
*** Vertical Ordering
|
|
||||||
- Organise code top down:
|
|
||||||
- High-level concepts first (main logic).
|
|
||||||
- Lower-level details later.
|
|
||||||
- Readers can skim like a newspaper: important first, details last.
|
|
||||||
- Contrast: C/C++ require declarations before use, Java does not.
|
|
||||||
|
|
||||||
*** Summary - vertical
|
|
||||||
- Use vertical openness to separate concepts.
|
|
||||||
- Use vertical density to group related ones.
|
|
||||||
- Keep related variables, methods, and concepts close together.
|
|
||||||
- Order code top down for natural readability.
|
|
||||||
|
|
||||||
** Horizontal Formatting
|
|
||||||
|
|
||||||
Keep lines short. Most professional code naturally stays within ~45 characters, with ~80 as an upper bound. Lines beyond 100–120 characters are generally careless.
|
|
||||||
|
|
||||||
Avoid shrinking font or overly wide monitors to fit more code, readability > fitting more characters.
|
|
||||||
|
|
||||||
Example limit guideline:
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
// Good (short)
|
|
||||||
int sum = a + b + c;
|
|
||||||
|
|
||||||
// Bad (too long)
|
|
||||||
int sum = a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q;
|
|
||||||
#+end_src
|
|
||||||
*** Horizontal Openness and Density
|
|
||||||
|
|
||||||
Use spaces to separate low-precedence operators (e.g., +, -, =) and improve readability.
|
|
||||||
|
|
||||||
Do not put spaces between function names and parentheses, they are closely related.
|
|
||||||
|
|
||||||
Example (Quadratic formula formatting):
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
return (-b + Math.sqrt(determinant)) / (2*a);
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
Separate arguments with spaces after commas to show distinct parameters.
|
|
||||||
|
|
||||||
*** Horizontal Alignment
|
|
||||||
Avoid aligning variable declarations or assignments in columns, it draws the eye to the wrong place.
|
|
||||||
|
|
||||||
Long aligned lists usually mean the class is too large and should be split.
|
|
||||||
|
|
||||||
Example (preferred unaligned):
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
// Prefer this:
|
|
||||||
private Socket socket;
|
|
||||||
private InputStream input;
|
|
||||||
private OutputStream output;
|
|
||||||
|
|
||||||
//instead of:
|
|
||||||
private Socket socket;
|
|
||||||
private InputStream input;
|
|
||||||
private OutputStream output;
|
|
||||||
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Indentation
|
|
||||||
|
|
||||||
Indent according to scope hierarchy:
|
|
||||||
|
|
||||||
Classes → no indent
|
|
||||||
|
|
||||||
Methods → 1 level
|
|
||||||
|
|
||||||
Method bodies → 2 levels
|
|
||||||
|
|
||||||
Inner blocks → +1 for each nesting
|
|
||||||
|
|
||||||
Indentation makes scopes visually obvious; without it, code is hard to scan.
|
|
||||||
|
|
||||||
Avoid collapsing scopes onto one line, always use braces and proper indenting.
|
|
||||||
|
|
||||||
*** Dummy Scopes
|
|
||||||
|
|
||||||
Avoid dummy bodies in loops (e.g., empty while or for loops).
|
|
||||||
|
|
||||||
If unavoidable, place semicolon on its own indented line to make it visible.
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
while (dis.read(buf, 0, size) != -1)
|
|
||||||
;
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
** Team Rules
|
|
||||||
|
|
||||||
Teams must agree on a single formatting style for consistency.
|
|
||||||
|
|
||||||
Use IDE formatters to enforce these rules across all files.
|
|
||||||
|
|
||||||
Consistent formatting builds trust and reduces mental load for readers.
|
|
||||||
|
|
||||||
** Uncle Bob’s Formatting Rules (Example in CodeAnalyzer.java)
|
|
||||||
|
|
||||||
Short, clear methods with consistent spacing and indentation.
|
|
||||||
|
|
||||||
Use spaces around assignment and low-precedence operators, no space for high precedence operators.
|
|
||||||
|
|
||||||
Avoid deeply nested structures. Prefer clear, flat logic.
|
|
||||||
|
|
||||||
Example snippet:
|
|
||||||
|
|
||||||
#+begin_src java
|
|
||||||
private void measureLine(String line) {
|
|
||||||
lineCount++;
|
|
||||||
int lineSize = line.length();
|
|
||||||
totalChars += lineSize;
|
|
||||||
lineWidthHistogram.addLine(lineSize, lineCount);
|
|
||||||
recordWidestLine(lineSize);
|
|
||||||
}
|
|
||||||
#+end_src
|
|
||||||
@@ -518,3 +518,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/gitea-build-mon
|
|||||||
2026-07-08T01:02:07.5213424+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
2026-07-08T01:02:07.5213424+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||||
2026-07-08T01:02:07.5955175+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
2026-07-08T01:02:07.5955175+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||||
2026-07-08T01:02:08.0097024+01:00 [INFO] Sent authoring server test notification.
|
2026-07-08T01:02:08.0097024+01:00 [INFO] Sent authoring server test notification.
|
||||||
|
2026-07-08T21:39:46.0576365+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
|
||||||
|
2026-07-08T21:39:46.0662048+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
|
||||||
|
2026-07-08T21:39:47.2301237+01:00 [INFO] Sent build status notification with 1 embed(s).
|
||||||
|
2026-07-08T21:39:47.2502852+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||||
|
2026-07-08T21:39:47.3360182+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||||
|
2026-07-08T21:39:47.6841173+01:00 [INFO] Sent authoring server test notification.
|
||||||
2
gitea-build-monitor.ps1 → build-logs/gitea-build-monitor.ps1
Executable file → Normal file
2
gitea-build-monitor.ps1 → build-logs/gitea-build-monitor.ps1
Executable file → Normal file
@@ -53,7 +53,7 @@ $Config = @{
|
|||||||
CacheFile = Join-Path $PSScriptRoot 'gitea-build-monitor-cache.json'
|
CacheFile = Join-Path $PSScriptRoot 'gitea-build-monitor-cache.json'
|
||||||
LogFile = Join-Path $PSScriptRoot 'gitea-build-monitor.log'
|
LogFile = Join-Path $PSScriptRoot 'gitea-build-monitor.log'
|
||||||
BuildLogFile = Join-Path $PSScriptRoot 'org-web-build.log'
|
BuildLogFile = Join-Path $PSScriptRoot 'org-web-build.log'
|
||||||
BuildSuccessPattern = 'JSON saved to output/test.json'
|
BuildSuccessPattern = 'JSON saved to output/search-index.json'
|
||||||
MaxJobsPerRepository = 50
|
MaxJobsPerRepository = 50
|
||||||
MaxLogBytesToAnalyze = 250000
|
MaxLogBytesToAnalyze = 250000
|
||||||
MaxDiscordEmbeds = 10
|
MaxDiscordEmbeds = 10
|
||||||
6
org-web-build.log → build-logs/org-web-build.log
Executable file → Normal file
6
org-web-build.log → build-logs/org-web-build.log
Executable file → Normal file
@@ -649,12 +649,6 @@ Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/hid
|
|||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/misc.css using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/misc.css using ‘org-publish-attachment’
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/kanban.css using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/kanban.css using ‘org-publish-attachment’
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/play.css using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/styles/play.css using ‘org-publish-attachment’
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/sensei-chi.jpeg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/z.jpeg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/lima.jpg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/young-z.jpeg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/aphy.jpeg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/avatars/future-z.jpeg using ‘org-publish-attachment’
|
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/23-11-2025-retrospectives.svg using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/23-11-2025-retrospectives.svg using ‘org-publish-attachment’
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/30-11-2025-retrospectives.svg using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/30-11-2025-retrospectives.svg using ‘org-publish-attachment’
|
||||||
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/25-01-26-retro.png using ‘org-publish-attachment’
|
Publishing file /home/zaine/master-folder/org-platform/org_web/assets/sketchnotes/25-01-26-retro.png using ‘org-publish-attachment’
|
||||||
840
build-site.el
840
build-site.el
@@ -1,23 +1,15 @@
|
|||||||
;;; build-site.el --- Publish my website using org-publish -*- lexical-binding: t; -*-
|
;;; build-site.el --- Publish my website using org-publish -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
;; Author: Zaine Qayyum <zaineulabideen@outlook.com>
|
;; Run with:
|
||||||
;; Created: 2025-08-08
|
|
||||||
;; Purpose: Build and publish my static site from Org files.
|
|
||||||
|
|
||||||
;;; Commentary:
|
|
||||||
;; Run this file with:
|
|
||||||
;; emacs -Q --script build-site.el
|
;; emacs -Q --script build-site.el
|
||||||
;;
|
;;
|
||||||
;; Optional env vars:
|
;; Optional env vars:
|
||||||
;; SITE_FORCE=1 Force republish all files (default: incremental)
|
;; SITE_FORCE=1 Force republish all files (default: incremental)
|
||||||
;; SITE_DRY=1 Parse and prepare only; skip org-publish-all
|
;; SITE_DRY=1 Parse and prepare only; skip org-publish-all
|
||||||
|
;; SITE_CLEAN=1 Wipe output/ and .org-timestamps/ before publishing
|
||||||
|
|
||||||
;;; Code:
|
;;; Code:
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 0. Bootstrap: paths and load-path
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defconst z/build-start-time (float-time)
|
(defconst z/build-start-time (float-time)
|
||||||
"Wall-clock time when the build started.")
|
"Wall-clock time when the build started.")
|
||||||
|
|
||||||
@@ -25,11 +17,8 @@
|
|||||||
(file-name-as-directory
|
(file-name-as-directory
|
||||||
(file-name-directory
|
(file-name-directory
|
||||||
(or load-file-name buffer-file-name)))
|
(or load-file-name buffer-file-name)))
|
||||||
"Absolute path to the site root (always ends with /).
|
"Absolute path to the site root.")
|
||||||
This is the single authoritative definition — lisp/* files must NOT
|
|
||||||
redefine site-root themselves.")
|
|
||||||
|
|
||||||
;; Keep the old name around so any stray code still compiles.
|
|
||||||
(defvaralias 'site-root 'z/site-root)
|
(defvaralias 'site-root 'z/site-root)
|
||||||
|
|
||||||
(setq create-lockfiles nil)
|
(setq create-lockfiles nil)
|
||||||
@@ -43,9 +32,7 @@ redefine site-root themselves.")
|
|||||||
(expand-file-name
|
(expand-file-name
|
||||||
(or (getenv "SITE_OUTPUT_DIR")
|
(or (getenv "SITE_OUTPUT_DIR")
|
||||||
(site-path "output/"))))
|
(site-path "output/"))))
|
||||||
"Absolute path to the site output directory.
|
"Absolute path to the site output directory.")
|
||||||
Defaults to output/ under `z/site-root', but CI can set SITE_OUTPUT_DIR to
|
|
||||||
publish into a stable directory instead of the runner's transient checkout.")
|
|
||||||
|
|
||||||
(defun output-path (path)
|
(defun output-path (path)
|
||||||
"Expand PATH relative to `z/output-root'."
|
"Expand PATH relative to `z/output-root'."
|
||||||
@@ -53,805 +40,24 @@ publish into a stable directory instead of the runner's transient checkout.")
|
|||||||
|
|
||||||
(add-to-list 'load-path (site-path "lisp"))
|
(add-to-list 'load-path (site-path "lisp"))
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
(require 'build-core)
|
||||||
;; 1. Logging helpers (ANSI colours for terminal output)
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
(z/bootstrap-packages)
|
||||||
|
(z/configure-org)
|
||||||
(defconst z/colour-reset "\033[0m")
|
|
||||||
(defconst z/colour-bold "\033[1m")
|
(dolist (library '(recently-updated
|
||||||
(defconst z/colour-dim "\033[2m")
|
tags
|
||||||
(defconst z/colour-green "\033[32m")
|
sidenotes
|
||||||
(defconst z/colour-yellow "\033[33m")
|
sitemaps
|
||||||
(defconst z/colour-cyan "\033[36m")
|
build-assets
|
||||||
(defconst z/colour-red "\033[31m")
|
build-comments
|
||||||
(defconst z/colour-blue "\033[34m")
|
build-html
|
||||||
(defconst z/colour-magenta "\033[35m")
|
build-lima
|
||||||
|
build-projects
|
||||||
(defun z/log (level fmt &rest args)
|
build-runner))
|
||||||
"Emit a coloured build-log line.
|
(z/require-local library))
|
||||||
LEVEL is one of: info warn error step metric done.
|
|
||||||
FMT / ARGS are passed to `format'."
|
(z/configure-publish-projects)
|
||||||
(let* ((msg (apply #'format fmt args))
|
(z/run-build)
|
||||||
(prefix
|
|
||||||
(pcase level
|
|
||||||
('info (concat z/colour-cyan " INFO " z/colour-reset))
|
|
||||||
('warn (concat z/colour-yellow " WARN " z/colour-reset))
|
|
||||||
('error (concat z/colour-red " ERROR " z/colour-reset))
|
|
||||||
('step (concat z/colour-blue z/colour-bold
|
|
||||||
" ──── " z/colour-reset))
|
|
||||||
('metric (concat z/colour-magenta " ◈ " z/colour-reset))
|
|
||||||
('done (concat z/colour-green z/colour-bold
|
|
||||||
" ✓ " z/colour-reset))
|
|
||||||
(_ " "))))
|
|
||||||
(message "%s%s" prefix msg)))
|
|
||||||
|
|
||||||
(defun z/log-separator (&optional label)
|
|
||||||
"Print a coloured separator line, optionally with LABEL."
|
|
||||||
(if label
|
|
||||||
(message "%s%s── %s %s%s"
|
|
||||||
z/colour-dim z/colour-bold label
|
|
||||||
(make-string (max 0 (- 60 (length label))) ?─)
|
|
||||||
z/colour-reset)
|
|
||||||
(message "%s%s%s"
|
|
||||||
z/colour-dim
|
|
||||||
(make-string 66 ?─)
|
|
||||||
z/colour-reset)))
|
|
||||||
|
|
||||||
(defmacro z/with-timing (label &rest body)
|
|
||||||
"Execute BODY, then log elapsed seconds under LABEL."
|
|
||||||
(declare (indent 1))
|
|
||||||
(let ((t0 (gensym "t0")))
|
|
||||||
`(let ((,t0 (float-time)))
|
|
||||||
(prog1 (progn ,@body)
|
|
||||||
(z/log 'metric "%s took %.2fs" ,label (- (float-time) ,t0))))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 2. Package bootstrap
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(z/log-separator "Package setup")
|
|
||||||
|
|
||||||
(require 'package)
|
|
||||||
|
|
||||||
(setq package-user-dir (site-path ".packages"))
|
|
||||||
(setq package-archives
|
|
||||||
'(("melpa" . "https://melpa.org/packages/")
|
|
||||||
("elpa" . "https://elpa.gnu.org/packages/")))
|
|
||||||
|
|
||||||
(package-initialize)
|
|
||||||
|
|
||||||
(z/with-timing "Package archive refresh"
|
|
||||||
(unless package-archive-contents
|
|
||||||
(z/log 'info "Refreshing package archive contents…")
|
|
||||||
(condition-case err
|
|
||||||
(package-refresh-contents)
|
|
||||||
(error
|
|
||||||
(z/log 'warn "Could not refresh archives: %s" (error-message-string err))))))
|
|
||||||
|
|
||||||
(z/with-timing "htmlize install"
|
|
||||||
(unless (package-installed-p 'htmlize)
|
|
||||||
(z/log 'info "Installing htmlize…")
|
|
||||||
(condition-case err
|
|
||||||
(package-install 'htmlize)
|
|
||||||
(error
|
|
||||||
(z/log 'error "htmlize install failed: %s" (error-message-string err))
|
|
||||||
(error "Cannot continue without htmlize")))))
|
|
||||||
|
|
||||||
(require 'htmlize)
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 3. Org / ox requires
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(require 'ox-publish)
|
|
||||||
(require 'cl-lib)
|
|
||||||
(require 'json)
|
|
||||||
(require 'org)
|
|
||||||
(require 'ox)
|
|
||||||
(require 'ox-html)
|
|
||||||
(require 'ox-md)
|
|
||||||
|
|
||||||
(setq org-publish-timestamp-directory (site-path ".org-timestamps/"))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 4. Local library requires (after load-path is set)
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(z/log-separator "Local libraries")
|
|
||||||
|
|
||||||
(dolist (lib '(recently-updated tags sidenotes sitemaps))
|
|
||||||
(condition-case err
|
|
||||||
(progn
|
|
||||||
(require lib)
|
|
||||||
(z/log 'done "Loaded %s" lib))
|
|
||||||
(error
|
|
||||||
(z/log 'error "Failed to load %s: %s" lib (error-message-string err))
|
|
||||||
(error "Aborting: required library %s missing" lib))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 5. HTML export settings
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(setq org-html-htmlize-output-type 'css)
|
|
||||||
|
|
||||||
(defun z/asset-version (path)
|
|
||||||
"Return a cache-busting version string for asset PATH under `z/site-root'."
|
|
||||||
(let* ((file (site-path path))
|
|
||||||
(attrs (and (file-exists-p file) (file-attributes file)))
|
|
||||||
(mtime (and attrs (file-attribute-modification-time attrs)))
|
|
||||||
(size (and attrs (file-attribute-size attrs))))
|
|
||||||
(if (and mtime size)
|
|
||||||
(format "%x-%x" (floor (float-time mtime)) size)
|
|
||||||
(format "%x" (floor z/build-start-time)))))
|
|
||||||
|
|
||||||
(defun z/asset-url (path)
|
|
||||||
"Return root-relative asset PATH with a deterministic cache-busting query."
|
|
||||||
(format "/%s?v=%s" path (z/asset-version path)))
|
|
||||||
|
|
||||||
(defvar z/shared-head
|
|
||||||
(concat
|
|
||||||
"<link rel=\"icon\" type=\"image/png\" sizes=\"48x48\""
|
|
||||||
" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
|
|
||||||
(mapconcat
|
|
||||||
(lambda (f)
|
|
||||||
(format "<link rel=\"stylesheet\" href=\"%s\" />"
|
|
||||||
(z/asset-url (format "assets/styles/%s" f))))
|
|
||||||
'("style.css"
|
|
||||||
"bigger-picture.min.css"
|
|
||||||
"comments.css"
|
|
||||||
"kanban.css"
|
|
||||||
"org-syntax.css"
|
|
||||||
"misc.css"
|
|
||||||
"media.css"
|
|
||||||
"wird-tracker.css"
|
|
||||||
"zhd.css"
|
|
||||||
"play.css"
|
|
||||||
"hidden-details.css"
|
|
||||||
)
|
|
||||||
"\n")
|
|
||||||
"\n"
|
|
||||||
(mapconcat
|
|
||||||
(lambda (f)
|
|
||||||
(format "<script src=\"%s\" defer></script>"
|
|
||||||
(z/asset-url (format "assets/scripts/%s" f))))
|
|
||||||
'("script.js"
|
|
||||||
"lunr.js"
|
|
||||||
"competency-status-board.js"
|
|
||||||
"notes.js"
|
|
||||||
"comments.js"
|
|
||||||
"bigger-picture.min.js"
|
|
||||||
"search.js"
|
|
||||||
"svg-pan-zoom.min.js"
|
|
||||||
"gallery-init.js"
|
|
||||||
"sitemap-interactive.js"
|
|
||||||
"wird-tracker.js"
|
|
||||||
"zhd.js"
|
|
||||||
"play.js"
|
|
||||||
"ash-below-lake.js"
|
|
||||||
"hidden-details.js"
|
|
||||||
)
|
|
||||||
"\n")
|
|
||||||
(format "<script src=\"%s\"></script>"
|
|
||||||
(z/asset-url "assets/scripts/mermaid.min.js"))
|
|
||||||
)
|
|
||||||
"Shared <head> HTML fragment injected into every page.")
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 6. Global Org macros
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(setq org-export-global-macros
|
|
||||||
(append
|
|
||||||
org-export-global-macros
|
|
||||||
'(("sidenote"
|
|
||||||
. "@@html:<label for=\"sn$1\" class=\"margin-toggle sidenote-number\"></label>\
|
|
||||||
<input type=\"checkbox\" id=\"sn$1\" class=\"margin-toggle\"/>\
|
|
||||||
<span class=\"sidenote\">$2</span>@@")
|
|
||||||
("epigraph"
|
|
||||||
. "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2</footer></blockquote></div>@@")
|
|
||||||
("epigraph_single"
|
|
||||||
. "@@html:<div class=\"epigraph\"><blockquote>$1</blockquote></div>@@")
|
|
||||||
("epigraph3"
|
|
||||||
. "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2, <cite>$3</cite></footer></blockquote></div>@@")
|
|
||||||
("kbd"
|
|
||||||
. "@@html:<kbd>$1</kbd>@@@@latex:\\texttt{$1}@@")
|
|
||||||
("margimg"
|
|
||||||
. "@@html:<aside class=\"marginnote\"><figure class=\"mn-fig\">\
|
|
||||||
<img src=\"$1\" alt=\"$2\" class=\"mn-img\" loading=\"lazy\" decoding=\"async\"/>$3\
|
|
||||||
</figure></aside>@@")
|
|
||||||
("countdown"
|
|
||||||
. "@@html:<time class=\"countdown\" datetime=\"$1\" data-label=\"$2\"></time>@@"))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 7. Preamble / postamble
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defvar z/preamble
|
|
||||||
"<div class=\"banner-header\" role=\"banner\">
|
|
||||||
<a class=\"site-brand\" href=\"/\" aria-label=\"Home\">
|
|
||||||
<img src=\"/assets/images/gr.png\" alt=\"\" class=\"banner-logo\" />
|
|
||||||
<span class=\"site-brand__text\">zxh</span>
|
|
||||||
</a>
|
|
||||||
<nav class=\"site-nav\" aria-label=\"Primary\">
|
|
||||||
<a href=\"/\">Home</a>
|
|
||||||
<a href=\"/blogs/blogs-list.html\">Blogs</a>
|
|
||||||
<a href=\"/posts/career/career-list.html\">Career</a>
|
|
||||||
<a href=\"/play.html\">Play</a>
|
|
||||||
<a href=\"https://zone.zainezq.com\">Dashboard</a>
|
|
||||||
</nav>
|
|
||||||
<div class=\"site-actions\">
|
|
||||||
<label class=\"site-search\" for=\"search-input\">
|
|
||||||
<span class=\"visually-hidden\">Search notes</span>
|
|
||||||
<input type=\"search\" id=\"search-input\" placeholder=\"Search notes\" aria-label=\"Search notes\" />
|
|
||||||
</label>
|
|
||||||
<button id=\"search-btn\" aria-label=\"Search\" type=\"button\">🔍</button>
|
|
||||||
<button class=\"theme-toggle\" id=\"theme-toggle\" type=\"button\" aria-label=\"Switch theme\">Theme</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id=\"updated\">Updated: %C</div>
|
|
||||||
")
|
|
||||||
|
|
||||||
(defvar z/postamble
|
|
||||||
"<footer>
|
|
||||||
<div class=\"copyright-container\">
|
|
||||||
<div class=\"copyright\">
|
|
||||||
Copyright © 2022-2026 Zaine Qayyum. All rights reserved unless otherwise noted.</div></div>
|
|
||||||
<div class=\"generated\">
|
|
||||||
Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
|
||||||
<a href=\"https://www.gnu.org\">GNU</a>/<a href=\"https://www.kernel.org/\">Linux</a>
|
|
||||||
</div>
|
|
||||||
</footer>")
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 8. Comments support
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/comments-file-p (file)
|
|
||||||
"Return non-nil if FILE has #+COMMENTS: t."
|
|
||||||
(when (file-exists-p file)
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents file)
|
|
||||||
(org-mode)
|
|
||||||
(let* ((keywords (org-collect-keywords '("COMMENTS")))
|
|
||||||
(val (cadr (assoc "COMMENTS" keywords))))
|
|
||||||
(and val
|
|
||||||
(string-match-p "^\\s-*t\\s-*$" (downcase val)))))))
|
|
||||||
|
|
||||||
(defun z/comments-file-slug (file)
|
|
||||||
"Return page slug from #+SLUG: or fallback to filename base."
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents file)
|
|
||||||
(org-mode)
|
|
||||||
(let* ((keywords (org-collect-keywords '("SLUG")))
|
|
||||||
(slug (cadr (assoc "SLUG" keywords))))
|
|
||||||
(if (and slug (string-match-p "\\S-" slug))
|
|
||||||
slug
|
|
||||||
(file-name-base file)))))
|
|
||||||
|
|
||||||
(defun z/comment-page-url (file)
|
|
||||||
"Return root-relative published HTML URL for source FILE."
|
|
||||||
(concat "/"
|
|
||||||
(file-name-sans-extension
|
|
||||||
(file-relative-name file z/site-root))
|
|
||||||
".html"))
|
|
||||||
|
|
||||||
(defun z/comment-page-title (file)
|
|
||||||
"Return exported page title for FILE, or the file basename."
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents file nil 0 4096)
|
|
||||||
(goto-char (point-min))
|
|
||||||
(if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t)
|
|
||||||
(match-string 1)
|
|
||||||
(file-name-base file))))
|
|
||||||
|
|
||||||
(defun z/comment-page-record (file)
|
|
||||||
"Return a JSON-ready comment page record for FILE."
|
|
||||||
`((slug . ,(z/comments-file-slug file))
|
|
||||||
(title . ,(z/comment-page-title file))
|
|
||||||
(url . ,(z/comment-page-url file))))
|
|
||||||
|
|
||||||
(defun z/generate-comment-pages-json ()
|
|
||||||
"Write a slug-to-page manifest for comment-enabled Org files."
|
|
||||||
(let* ((files (directory-files-recursively z/site-root "\\.org\\'"))
|
|
||||||
(records
|
|
||||||
(cl-loop for file in files
|
|
||||||
unless (or (string-prefix-p (site-path "output/") file)
|
|
||||||
(string-prefix-p (site-path "backups/") file)
|
|
||||||
(string-prefix-p (site-path ".org-timestamps/") file)
|
|
||||||
(string-prefix-p (site-path ".packages/") file))
|
|
||||||
when (z/comments-file-p file)
|
|
||||||
collect (z/comment-page-record file)))
|
|
||||||
(target (site-path "assets/content/comment-pages.json"))
|
|
||||||
(json-encoding-pretty-print t))
|
|
||||||
(make-directory (file-name-directory target) t)
|
|
||||||
(with-temp-file target
|
|
||||||
(insert (json-encode (vconcat records)))
|
|
||||||
(insert "\n"))
|
|
||||||
(z/log 'done "Wrote comment page manifest (%d pages)" (length records))))
|
|
||||||
|
|
||||||
(defun z/org-html-insert-comments-into-body (body backend info)
|
|
||||||
"Append a comments section to BODY for files that opt in."
|
|
||||||
(when (org-export-derived-backend-p backend 'html)
|
|
||||||
(let ((input-file (plist-get info :input-file)))
|
|
||||||
(if (and input-file (z/comments-file-p input-file))
|
|
||||||
(let* ((slug (z/comments-file-slug input-file))
|
|
||||||
(html (format
|
|
||||||
"\n<section id=\"comments\" class=\"comments\" data-slug=\"%s\">
|
|
||||||
<h2>Comments</h2>
|
|
||||||
<div id=\"comments-list\" class=\"comments-list\">
|
|
||||||
<noscript>Please enable JavaScript to view comments.</noscript>
|
|
||||||
</div>
|
|
||||||
<form id=\"comment-form\" class=\"comment-form\">
|
|
||||||
<label class=\"comment-author\">
|
|
||||||
<span>Name (optional)</span>
|
|
||||||
<input type=\"text\" name=\"author\"/>
|
|
||||||
</label>
|
|
||||||
<label class=\"comment-content\">
|
|
||||||
<span>Your comment</span>
|
|
||||||
<textarea name=\"content\" required></textarea>
|
|
||||||
</label>
|
|
||||||
<button type=\"submit\">Post comment</button>
|
|
||||||
</form>
|
|
||||||
</section>\n"
|
|
||||||
slug)))
|
|
||||||
(concat body html))
|
|
||||||
body))))
|
|
||||||
|
|
||||||
|
|
||||||
;; 8.5 mermaid js support:
|
|
||||||
|
|
||||||
(defun z/org-html-mermaid-block (code _lang)
|
|
||||||
"Export a mermaid src block as a mermaid div."
|
|
||||||
(format "<div class=\"mermaid\">\n%s\n</div>" code))
|
|
||||||
|
|
||||||
;; Hook into ox-html's src block export
|
|
||||||
(defun z/org-html-src-block-mermaid (orig-fun src-block contents info)
|
|
||||||
(let ((lang (org-element-property :language src-block)))
|
|
||||||
(if (string= lang "mermaid")
|
|
||||||
(let ((code (org-remove-indentation
|
|
||||||
(org-element-property :value src-block))))
|
|
||||||
(format "<div class=\"mermaid\">\n%s\n</div>" code))
|
|
||||||
(funcall orig-fun src-block contents info))))
|
|
||||||
|
|
||||||
(advice-add 'org-html-src-block :around #'z/org-html-src-block-mermaid)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 9. Body-class injection (WIP / no-sidenotes)
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/org-html-add-body-classes (output backend info)
|
|
||||||
"Add layout-related classes to <body> based on file metadata."
|
|
||||||
(when (org-export-derived-backend-p backend 'html)
|
|
||||||
(let* ((input-file (plist-get info :input-file))
|
|
||||||
(classes
|
|
||||||
(delq nil
|
|
||||||
(list
|
|
||||||
(when (and input-file (z/wip-file-p input-file))
|
|
||||||
"wip")
|
|
||||||
(when (and input-file (z/no-sidenotes-file-p input-file))
|
|
||||||
"no-sidenotes")))))
|
|
||||||
(if classes
|
|
||||||
(replace-regexp-in-string
|
|
||||||
"<body\\([^>]*\\)>"
|
|
||||||
(format "<body\\1 class=\"%s\">"
|
|
||||||
(string-join classes " "))
|
|
||||||
output)
|
|
||||||
output))))
|
|
||||||
|
|
||||||
(add-to-list 'org-export-filter-final-output-functions
|
|
||||||
#'z/org-html-add-body-classes)
|
|
||||||
(add-to-list 'org-export-filter-body-functions
|
|
||||||
#'z/org-html-insert-comments-into-body)
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 10. Custom z-html backend
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(org-export-define-derived-backend 'z-html 'html
|
|
||||||
:filters-alist '((:filter-final-output . z/insert-filetags-after-title)))
|
|
||||||
|
|
||||||
(defun z/z-publish-to-html (plist filename pub-dir)
|
|
||||||
"Publish FILENAME to HTML using the z-html backend."
|
|
||||||
(org-publish-org-to 'z-html filename ".html" plist pub-dir))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 11. Lima project helpers
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/lima-sitemap-format-entry (entry style project)
|
|
||||||
"Format a sitemap entry for Lima; skip directories."
|
|
||||||
(let* ((file (if (listp entry) (car entry) entry))
|
|
||||||
(base-dir (file-name-as-directory
|
|
||||||
(org-publish-property :base-directory project)))
|
|
||||||
(abs (if (file-name-absolute-p file)
|
|
||||||
file
|
|
||||||
(expand-file-name file base-dir))))
|
|
||||||
(unless (file-directory-p abs)
|
|
||||||
(let* ((rel (file-relative-name abs base-dir))
|
|
||||||
(rel-noext (file-name-sans-extension rel))
|
|
||||||
(title (org-publish-find-title abs project)))
|
|
||||||
(format "[[file:%s.html][%s]]"
|
|
||||||
rel-noext
|
|
||||||
(or title (file-name-nondirectory rel-noext)))))))
|
|
||||||
|
|
||||||
(defun z/copy-neighbor-attachments (source-dir pub-dir)
|
|
||||||
"Copy sibling .attachments.* directories from SOURCE-DIR into PUB-DIR."
|
|
||||||
(dolist (d (directory-files source-dir t "^\\.attachments\\..+"))
|
|
||||||
(when (file-directory-p d)
|
|
||||||
(let ((target (expand-file-name (file-name-nondirectory d) pub-dir)))
|
|
||||||
(make-directory target t)
|
|
||||||
(copy-directory d target t t t)))))
|
|
||||||
|
|
||||||
(defun z/markdown-h1-title (filename fallback)
|
|
||||||
"Return the first Markdown H1 title in FILENAME, or FALLBACK."
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents filename nil 0 4096)
|
|
||||||
(goto-char (point-min))
|
|
||||||
(if (re-search-forward "^#+ +\\(.+?\\)\\s-*$" nil t)
|
|
||||||
(match-string 1)
|
|
||||||
fallback)))
|
|
||||||
|
|
||||||
(defun z/publish-lima-file (plist filename pub-dir)
|
|
||||||
"Publish an Org or Markdown file from the lima directory."
|
|
||||||
(let* ((ext (downcase (or (file-name-extension filename) "")))
|
|
||||||
(base (file-name-base filename))
|
|
||||||
(src-dir (file-name-directory filename)))
|
|
||||||
(cond
|
|
||||||
;; ── Org files ──────────────────────────────────────────────────────────
|
|
||||||
((string= ext "org")
|
|
||||||
(z/copy-neighbor-attachments src-dir pub-dir)
|
|
||||||
(org-publish-org-to 'z-html filename ".html" plist pub-dir))
|
|
||||||
|
|
||||||
;; ── Markdown files ─────────────────────────────────────────────────────
|
|
||||||
((string= ext "md")
|
|
||||||
(let* ((tmp-dir (make-temp-file "lima-build-" t))
|
|
||||||
(temp-org (expand-file-name (concat base ".org") tmp-dir)))
|
|
||||||
;; Markdown → Org
|
|
||||||
(let ((rc (call-process "pandoc" nil nil nil
|
|
||||||
filename "-f" "markdown" "-t" "org" "-o" temp-org)))
|
|
||||||
(unless (zerop rc)
|
|
||||||
(z/log 'error "pandoc failed (rc=%d) for %s" rc filename)
|
|
||||||
(error "pandoc conversion failed for %s" filename)))
|
|
||||||
;; Prepend front matter
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents temp-org)
|
|
||||||
(goto-char (point-min))
|
|
||||||
(insert (format "#+TITLE: %s\n#+OPTIONS: num:nil\n#+DATE: %s\n#+COMMENTS: t\n#+SLUG: %s\n\n"
|
|
||||||
(z/markdown-h1-title filename base)
|
|
||||||
(format-time-string "<%Y-%m-%d %a %H:%M>")
|
|
||||||
base))
|
|
||||||
(write-region (point-min) (point-max) temp-org))
|
|
||||||
;; Publish
|
|
||||||
(z/copy-neighbor-attachments src-dir pub-dir)
|
|
||||||
(let ((output-file (expand-file-name (concat base ".html") pub-dir)))
|
|
||||||
(org-publish-org-to 'z-html temp-org ".html" plist pub-dir)
|
|
||||||
(let ((generated (expand-file-name
|
|
||||||
(concat (file-name-base temp-org) ".html")
|
|
||||||
pub-dir)))
|
|
||||||
(when (and (file-exists-p generated)
|
|
||||||
(not (string-equal generated output-file)))
|
|
||||||
(rename-file generated output-file t))))))
|
|
||||||
|
|
||||||
;; ── Fallback: treat as attachment ──────────────────────────────────────
|
|
||||||
(t
|
|
||||||
(org-publish-attachment plist filename pub-dir)))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 12. Preparation hooks
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/org-main-prep (_project)
|
|
||||||
"Pre-publish hook: regenerate recently-updated.org."
|
|
||||||
(z/log 'step "Generating recently-updated.org…")
|
|
||||||
(z/with-timing "recently-updated generation"
|
|
||||||
(z/generate-recently-updated-org 26)))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 13. Publishing project alist
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(setq org-publish-project-alist
|
|
||||||
`(("org-main"
|
|
||||||
:recursive t
|
|
||||||
:base-directory ,(site-path "")
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:publishing-directory ,(output-path "")
|
|
||||||
:base-extension "org"
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "sitemap.org"
|
|
||||||
:sitemap-title "Sitemap"
|
|
||||||
:sitemap-function z/main-sitemap
|
|
||||||
:sitemap-sort-files chronologically
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:html-head ,z/shared-head
|
|
||||||
:preparation-function z/org-main-prep)
|
|
||||||
|
|
||||||
("org-assets"
|
|
||||||
:base-directory ,(site-path "assets/")
|
|
||||||
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|woff\\|woff2\\|ttf"
|
|
||||||
:publishing-directory ,(output-path "assets/")
|
|
||||||
:recursive t
|
|
||||||
:publishing-function org-publish-attachment)
|
|
||||||
|
|
||||||
("org-categories-sitemap"
|
|
||||||
:recursive t
|
|
||||||
:base-directory ,(site-path "home/")
|
|
||||||
:publishing-directory ,(output-path "home/")
|
|
||||||
:base-extension "org"
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "categories.org"
|
|
||||||
:sitemap-title "Categories"
|
|
||||||
:sitemap-function z/categories-sitemap
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-posts"
|
|
||||||
:base-directory ,(site-path "posts/")
|
|
||||||
:publishing-directory ,(output-path "posts/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:with-author nil
|
|
||||||
:with-creator nil
|
|
||||||
:html-validation-link nil
|
|
||||||
:with-toc t
|
|
||||||
:section-numbers t
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "posts-list.org"
|
|
||||||
:sitemap-title "Posts List"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/posts-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-blogs"
|
|
||||||
:base-directory ,(site-path "blogs/")
|
|
||||||
:publishing-directory ,(output-path "blogs/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:with-author nil
|
|
||||||
:with-creator nil
|
|
||||||
:html-validation-link nil
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "blogs-list.org"
|
|
||||||
:sitemap-title "Blogs List"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/blogs-grouped-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-books"
|
|
||||||
:base-directory ,(site-path "books/")
|
|
||||||
:publishing-directory ,(output-path "books/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "books-list.org"
|
|
||||||
:sitemap-title "Books List"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/books-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-career"
|
|
||||||
:base-directory ,(site-path "posts/career/")
|
|
||||||
:publishing-directory ,(output-path "posts/career/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "career-list.org"
|
|
||||||
:sitemap-title "Career List"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/career-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("wip-pages"
|
|
||||||
:base-directory ,(site-path "")
|
|
||||||
:publishing-directory ,(output-path "")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "wip.org"
|
|
||||||
:sitemap-title "Work in progress"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/wip-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-tags"
|
|
||||||
:base-directory ,(site-path "tags/")
|
|
||||||
:publishing-directory ,(output-path "tags/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function org-html-publish-to-html
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:html-head ,z/shared-head)
|
|
||||||
|
|
||||||
("org-lima"
|
|
||||||
:base-directory ,(site-path "lima/")
|
|
||||||
:publishing-directory ,(output-path "lima/")
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org\\|md"
|
|
||||||
:publishing-function z/publish-lima-file
|
|
||||||
:with-author nil
|
|
||||||
:with-creator nil
|
|
||||||
:html-validation-link nil
|
|
||||||
:html-preamble ,z/preamble
|
|
||||||
:html-postamble ,z/postamble
|
|
||||||
:sitemap-filename "lima-list.org"
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-title "Lima"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:sitemap-format-entry z/lima-sitemap-format-entry
|
|
||||||
:html-head ,z/shared-head)))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 14. Pre-build clean-up (opt-in via SITE_CLEAN=1)
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;;
|
|
||||||
;; IMPORTANT: output/ and .org-timestamps/ are a matched pair.
|
|
||||||
;; .org-timestamps/ is org-publish's cache of every file's last-published
|
|
||||||
;; mtime. If output/ is wiped without also wiping .org-timestamps/, the next
|
|
||||||
;; incremental build sees "cache says up-to-date" but the output file is gone,
|
|
||||||
;; so it silently skips the file. That is why assets/ and index.html vanished.
|
|
||||||
;;
|
|
||||||
;; Rule: whenever output/ is deleted, .org-timestamps/ must be deleted too.
|
|
||||||
|
|
||||||
(z/log-separator "Pre-build")
|
|
||||||
(z/log 'info "Site source root : %s" z/site-root)
|
|
||||||
(z/log 'info "Site output root : %s" z/output-root)
|
|
||||||
|
|
||||||
(defun z/wipe-build-dirs ()
|
|
||||||
"Delete output/ and .org-timestamps/ together."
|
|
||||||
(dolist (dir (list z/output-root
|
|
||||||
(site-path ".org-timestamps/")))
|
|
||||||
(if (file-directory-p dir)
|
|
||||||
(progn
|
|
||||||
(z/with-timing (format "delete %s"
|
|
||||||
(file-name-nondirectory
|
|
||||||
(directory-file-name dir)))
|
|
||||||
(delete-directory dir t))
|
|
||||||
(z/log 'done "Deleted %s" dir))
|
|
||||||
(z/log 'info "Already absent: %s" dir))))
|
|
||||||
|
|
||||||
(defun z/output-integrity-ok-p ()
|
|
||||||
"Return t if critical output paths all exist.
|
|
||||||
Returns nil (and logs warnings) if assets/ or any top-level HTML is missing.
|
|
||||||
A nil result means the timestamp cache is stale and we must force-republish."
|
|
||||||
(let ((missing '()))
|
|
||||||
(unless (file-directory-p (output-path "assets/"))
|
|
||||||
(push "output/assets/" missing))
|
|
||||||
(unless (or (file-exists-p (output-path "index.html"))
|
|
||||||
(file-exists-p (output-path "sitemap.html")))
|
|
||||||
(push "output/index.html (or sitemap.html)" missing))
|
|
||||||
(dolist (m missing)
|
|
||||||
(z/log 'warn "Integrity check: missing %s" m))
|
|
||||||
(null missing)))
|
|
||||||
|
|
||||||
(cond
|
|
||||||
;; ── Explicit clean requested ───────────────────────────────────────────────
|
|
||||||
((getenv "SITE_CLEAN")
|
|
||||||
(z/log 'warn "SITE_CLEAN=1 — wiping output/ and .org-timestamps/")
|
|
||||||
(z/wipe-build-dirs)
|
|
||||||
(setenv "SITE_FORCE" "1"))
|
|
||||||
|
|
||||||
;; ── Output looks incomplete — cache is stale ──────────────────────────────
|
|
||||||
;; Catches the case where output/ was wiped externally (git clean, manual rm,
|
|
||||||
;; Syncthing conflict) without clearing .org-timestamps/. Without this guard
|
|
||||||
;; org-publish would silently skip every "up-to-date" file and produce a
|
|
||||||
;; broken site with missing assets and pages.
|
|
||||||
((not (z/output-integrity-ok-p))
|
|
||||||
(z/log 'warn "Output integrity check failed — timestamp cache is stale")
|
|
||||||
(z/log 'warn "Clearing .org-timestamps/ and forcing full republish")
|
|
||||||
(let ((ts-dir (site-path ".org-timestamps/")))
|
|
||||||
(when (file-directory-p ts-dir)
|
|
||||||
(z/with-timing "delete .org-timestamps"
|
|
||||||
(delete-directory ts-dir t))
|
|
||||||
(z/log 'done "Deleted .org-timestamps/")))
|
|
||||||
(setenv "SITE_FORCE" "1"))
|
|
||||||
|
|
||||||
;; ── Normal incremental build ──────────────────────────────────────────────
|
|
||||||
(t
|
|
||||||
(z/log 'info "Incremental build — output/ looks complete")))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 15. Tag pages
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(z/log-separator "Tag generation")
|
|
||||||
|
|
||||||
(z/with-timing "tag page generation"
|
|
||||||
(condition-case err
|
|
||||||
(let ((n (z/write-tag-pages)))
|
|
||||||
(z/log 'done "Tag pages written%s"
|
|
||||||
(if (numberp n) (format " (%d tags)" n) "")))
|
|
||||||
(error
|
|
||||||
(z/log 'error "Tag generation failed: %s" (error-message-string err))
|
|
||||||
(error "Aborting build: tag generation error"))))
|
|
||||||
|
|
||||||
(z/with-timing "comment page manifest"
|
|
||||||
(condition-case err
|
|
||||||
(z/generate-comment-pages-json)
|
|
||||||
(error
|
|
||||||
(z/log 'error "Comment page manifest failed: %s" (error-message-string err))
|
|
||||||
(error "Aborting build: comment page manifest error"))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 16. Publish
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(z/log-separator "Publishing")
|
|
||||||
|
|
||||||
(defun z/sync-assets-to-output ()
|
|
||||||
"Copy assets/ into the configured output assets directory, overwriting stale files."
|
|
||||||
(let ((source-root (site-path "assets/"))
|
|
||||||
(target-root (output-path "assets/")))
|
|
||||||
(when (file-directory-p source-root)
|
|
||||||
(dolist (file (directory-files-recursively source-root ".*" nil))
|
|
||||||
(unless (file-directory-p file)
|
|
||||||
(let* ((rel (file-relative-name file source-root))
|
|
||||||
(target (expand-file-name rel target-root)))
|
|
||||||
(make-directory (file-name-directory target) t)
|
|
||||||
(copy-file file target t t t t)))))))
|
|
||||||
|
|
||||||
(if (getenv "SITE_DRY")
|
|
||||||
(z/log 'warn "SITE_DRY=1 — skipping org-publish-all (dry run)")
|
|
||||||
(let ((force (not (not (getenv "SITE_FORCE")))))
|
|
||||||
(when force
|
|
||||||
(z/log 'info "SITE_FORCE=1 — forcing republish of all files"))
|
|
||||||
(condition-case err
|
|
||||||
(progn
|
|
||||||
(z/with-timing "org-publish-all"
|
|
||||||
(org-publish-all force))
|
|
||||||
(z/with-timing "asset sync"
|
|
||||||
(z/sync-assets-to-output)))
|
|
||||||
(error
|
|
||||||
(z/log 'error "org-publish-all failed: %s" (error-message-string err))
|
|
||||||
(error "Build failed")))))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; 17. Build summary
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(z/log-separator "Build summary")
|
|
||||||
|
|
||||||
(let* ((elapsed (- (float-time) z/build-start-time))
|
|
||||||
(out-dir z/output-root)
|
|
||||||
(html-files (when (file-directory-p out-dir)
|
|
||||||
(length (directory-files-recursively out-dir "\\.html\\'"))))
|
|
||||||
(css-files (when (file-directory-p out-dir)
|
|
||||||
(length (directory-files-recursively out-dir "\\.css\\'"))))
|
|
||||||
(js-files (when (file-directory-p out-dir)
|
|
||||||
(length (directory-files-recursively out-dir "\\.js\\'"))))
|
|
||||||
(tag-files (let ((td (output-path "tags/")))
|
|
||||||
(when (file-directory-p td)
|
|
||||||
(length (directory-files td nil "\\.html\\'"))))))
|
|
||||||
(z/log 'metric "Total elapsed : %.2fs" elapsed)
|
|
||||||
(z/log 'metric "HTML files output: %d" (or html-files 0))
|
|
||||||
(z/log 'metric "CSS files output: %d" (or css-files 0))
|
|
||||||
(z/log 'metric "JS files output: %d" (or js-files 0))
|
|
||||||
(z/log 'metric "Tag pages output: %d" (or tag-files 0))
|
|
||||||
(z/log 'done "Build complete ✓"))
|
|
||||||
|
|
||||||
;;; build-site.el ends here
|
;;; build-site.el ends here
|
||||||
|
|||||||
34
docs/assets-architecture.md
Normal file
34
docs/assets-architecture.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Asset Architecture
|
||||||
|
|
||||||
|
Source assets live under `assets/`. Generated copies under `output/` should be treated as build output.
|
||||||
|
|
||||||
|
## Styles
|
||||||
|
|
||||||
|
- `assets/styles/themes/`: design tokens and theme variables.
|
||||||
|
- `assets/styles/base/`: global HTML element defaults.
|
||||||
|
- `assets/styles/layout/`: site shell and page layout primitives.
|
||||||
|
- `assets/styles/components/`: reusable component styles.
|
||||||
|
- `assets/styles/pages/`: page-specific styles.
|
||||||
|
- `assets/styles/features/`: optional feature styles.
|
||||||
|
- `assets/styles/utilities/`: responsive and utility rules.
|
||||||
|
- `assets/styles/vendor/`: third-party CSS copied into the site.
|
||||||
|
|
||||||
|
The legacy top-level CSS files are compatibility manifests that import from these folders. Prefer editing the modular files.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `assets/scripts/core/`: framework-free domain logic.
|
||||||
|
- `assets/scripts/application/`: use-case orchestration.
|
||||||
|
- `assets/scripts/adapters/`: browser, storage, network, and external-service adapters.
|
||||||
|
- `assets/scripts/ui/`: DOM presentation behaviour.
|
||||||
|
- `assets/scripts/pages/`: page-specific applications.
|
||||||
|
- `assets/scripts/features/`: optional site features.
|
||||||
|
- `assets/scripts/vendor/`: third-party libraries copied into the site.
|
||||||
|
|
||||||
|
The Emacs build manifest in `lisp/build-assets.el` loads modular script paths directly. Top-level script files remain as legacy loaders for older hand-written references.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Keep core logic independent from the DOM, storage, network, and generated Org HTML. UI modules may read and write the DOM. Adapters isolate browser APIs and external services. Page scripts should compose these pieces without becoming shared dependencies.
|
||||||
|
|
||||||
|
Avatar image support has been removed. Do not add files under `assets/avatars/` or references to `/assets/avatars/`.
|
||||||
@@ -197,9 +197,6 @@ The script is as follows, at the start I have some metadata relating to the file
|
|||||||
(classes
|
(classes
|
||||||
(delq nil
|
(delq nil
|
||||||
(list
|
(list
|
||||||
(when (and input-file
|
|
||||||
(z/wip-file-p input-file))
|
|
||||||
"wip")
|
|
||||||
(when (and input-file
|
(when (and input-file
|
||||||
(z/no-sidenotes-file-p input-file))
|
(z/no-sidenotes-file-p input-file))
|
||||||
"no-sidenotes")))))
|
"no-sidenotes")))))
|
||||||
@@ -300,25 +297,6 @@ The script is as follows, at the start I have some metadata relating to the file
|
|||||||
:sitemap-function z/blogs-sitemap
|
:sitemap-function z/blogs-sitemap
|
||||||
:sitemap-sort-files anti-chronologically
|
:sitemap-sort-files anti-chronologically
|
||||||
:html-head ,z-shared-head
|
:html-head ,z-shared-head
|
||||||
)
|
|
||||||
("org-books"
|
|
||||||
:base-directory "~/master-folder/org-platform/org_web/books/"
|
|
||||||
:publishing-directory "~/master-folder/org-platform/org_web/output/books/"
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:with-author nil
|
|
||||||
:with-creator nil
|
|
||||||
:html-validation-link nil
|
|
||||||
:html-preamble ,z-preamble
|
|
||||||
:html-postamble ,z-postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "books-list.org"
|
|
||||||
:sitemap-title "Books List"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/books-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z-shared-head
|
|
||||||
)
|
)
|
||||||
("org-2025"
|
("org-2025"
|
||||||
:base-directory "~/master-folder/org-platform/org_web/blogs/2025/"
|
:base-directory "~/master-folder/org-platform/org_web/blogs/2025/"
|
||||||
@@ -358,25 +336,6 @@ The script is as follows, at the start I have some metadata relating to the file
|
|||||||
:sitemap-sort-files anti-chronologically
|
:sitemap-sort-files anti-chronologically
|
||||||
:html-head ,z-shared-head
|
:html-head ,z-shared-head
|
||||||
)
|
)
|
||||||
("wip-pages"
|
|
||||||
:base-directory "~/master-folder/org-platform/org_web/"
|
|
||||||
:publishing-directory "~/master-folder/org-platform/org_web/output/"
|
|
||||||
:recursive t
|
|
||||||
:base-extension "org"
|
|
||||||
:publishing-function z/z-publish-to-html
|
|
||||||
:with-author nil
|
|
||||||
:with-creator nil
|
|
||||||
:html-validation-link nil
|
|
||||||
:html-preamble ,z-preamble
|
|
||||||
:html-postamble ,z-postamble
|
|
||||||
:auto-sitemap t
|
|
||||||
:sitemap-filename "wip.org"
|
|
||||||
:sitemap-title "Work in progress"
|
|
||||||
:sitemap-style list
|
|
||||||
:sitemap-function z/wip-sitemap
|
|
||||||
:sitemap-sort-files anti-chronologically
|
|
||||||
:html-head ,z-shared-head
|
|
||||||
)
|
|
||||||
("org-tags"
|
("org-tags"
|
||||||
:base-directory "~/master-folder/org-platform/org_web/tags"
|
:base-directory "~/master-folder/org-platform/org_web/tags"
|
||||||
:publishing-directory "~/master-folder/org-platform/org_web/output/tags"
|
:publishing-directory "~/master-folder/org-platform/org_web/output/tags"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<span class="db-qn-label">Sitemap</span>
|
<span class="db-qn-label">Sitemap</span>
|
||||||
<span class="db-qn-meta">map</span>
|
<span class="db-qn-meta">map</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="db-qn-item db-qn-item--play" href="/play.html" data-keywords="play games interactive experiments fun">
|
<a class="db-qn-item db-qn-item--play" href="/play/play.html" data-keywords="play games interactive experiments fun">
|
||||||
<span class="db-qn-icon">◇</span>
|
<span class="db-qn-icon">◇</span>
|
||||||
<span class="db-qn-label">Play</span>
|
<span class="db-qn-label">Play</span>
|
||||||
<span class="db-qn-meta">lab</span>
|
<span class="db-qn-meta">lab</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#+TITLE: Lima
|
#+TITLE: Lima
|
||||||
|
|
||||||
- [[file:instructions.html][instructions]]
|
|
||||||
- [[file:index.html][index]]
|
- [[file:index.html][index]]
|
||||||
|
- [[file:instructions.html][instructions]]
|
||||||
83
lisp/build-assets.el
Normal file
83
lisp/build-assets.el
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
;;; build-assets.el --- Asset URLs and publishing helpers -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(defun z/asset-version (path)
|
||||||
|
"Return a cache-busting version string for asset PATH under `z/site-root'."
|
||||||
|
(let* ((file (site-path path))
|
||||||
|
(attrs (and (file-exists-p file) (file-attributes file)))
|
||||||
|
(mtime (and attrs (file-attribute-modification-time attrs)))
|
||||||
|
(size (and attrs (file-attribute-size attrs))))
|
||||||
|
(if (and mtime size)
|
||||||
|
(format "%x-%x" (floor (float-time mtime)) size)
|
||||||
|
(format "%x" (floor z/build-start-time)))))
|
||||||
|
|
||||||
|
(defun z/asset-url (path)
|
||||||
|
"Return root-relative asset PATH with a deterministic cache-busting query."
|
||||||
|
(format "/%s?v=%s" path (z/asset-version path)))
|
||||||
|
|
||||||
|
(defvar z/shared-head
|
||||||
|
(concat
|
||||||
|
"<link rel=\"icon\" type=\"image/png\" sizes=\"48x48\""
|
||||||
|
" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
|
||||||
|
(mapconcat
|
||||||
|
(lambda (f)
|
||||||
|
(format "<link rel=\"stylesheet\" href=\"%s\" />"
|
||||||
|
(z/asset-url (format "assets/styles/%s" f))))
|
||||||
|
'("style.css"
|
||||||
|
"bigger-picture.min.css"
|
||||||
|
"comments.css"
|
||||||
|
"kanban.css"
|
||||||
|
"org-syntax.css"
|
||||||
|
"misc.css"
|
||||||
|
"media.css"
|
||||||
|
"wird-tracker.css"
|
||||||
|
"zhd.css"
|
||||||
|
"play.css"
|
||||||
|
"hidden-details.css")
|
||||||
|
"\n")
|
||||||
|
"\n"
|
||||||
|
(mapconcat
|
||||||
|
(lambda (f)
|
||||||
|
(format "<script src=\"%s\" defer></script>"
|
||||||
|
(z/asset-url (format "assets/scripts/%s" f))))
|
||||||
|
'("ui/copy-buttons.js"
|
||||||
|
"ui/footnote-sidenotes.js"
|
||||||
|
"application/theme-switcher.js"
|
||||||
|
"ui/countdown.js"
|
||||||
|
"ui/toc-active-link.js"
|
||||||
|
"ui/mermaid-diagrams.js"
|
||||||
|
"vendor/lunr.js"
|
||||||
|
"pages/competency-status-board.js"
|
||||||
|
"pages/notes.js"
|
||||||
|
"features/comments.js"
|
||||||
|
"vendor/bigger-picture.min.js"
|
||||||
|
"search.js"
|
||||||
|
"vendor/svg-pan-zoom.min.js"
|
||||||
|
"ui/gallery-init.js"
|
||||||
|
"pages/sitemap-interactive.js"
|
||||||
|
"pages/wird-tracker.js"
|
||||||
|
"pages/home-dashboard.js"
|
||||||
|
"pages/play.js"
|
||||||
|
"pages/ash-below-lake.js"
|
||||||
|
"features/hidden-details.js")
|
||||||
|
"\n")
|
||||||
|
(format "<script src=\"%s\"></script>"
|
||||||
|
(z/asset-url "assets/scripts/vendor/mermaid.min.js")))
|
||||||
|
"Shared <head> HTML fragment injected into every page.")
|
||||||
|
|
||||||
|
(defun z/sync-assets-to-output ()
|
||||||
|
"Copy assets/ into the configured output assets directory, overwriting stale files."
|
||||||
|
(let ((source-root (site-path "assets/"))
|
||||||
|
(target-root (output-path "assets/")))
|
||||||
|
(when (file-directory-p source-root)
|
||||||
|
(dolist (file (directory-files-recursively source-root ".*" nil))
|
||||||
|
(unless (file-directory-p file)
|
||||||
|
(let* ((rel (file-relative-name file source-root))
|
||||||
|
(target (expand-file-name rel target-root)))
|
||||||
|
(make-directory (file-name-directory target) t)
|
||||||
|
(copy-file file target t t t t)))))))
|
||||||
|
|
||||||
|
(provide 'build-assets)
|
||||||
|
|
||||||
|
;;; build-assets.el ends here
|
||||||
103
lisp/build-comments.el
Normal file
103
lisp/build-comments.el
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
;;; build-comments.el --- Comment manifest and export filter -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'cl-lib)
|
||||||
|
(require 'json)
|
||||||
|
(require 'org)
|
||||||
|
|
||||||
|
(defun z/comments-file-p (file)
|
||||||
|
"Return non-nil if FILE has #+COMMENTS: t."
|
||||||
|
(when (and file (file-exists-p file))
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(org-mode)
|
||||||
|
(let* ((keywords (org-collect-keywords '("COMMENTS")))
|
||||||
|
(val (cadr (assoc "COMMENTS" keywords))))
|
||||||
|
(and val
|
||||||
|
(string-match-p "^\\s-*t\\s-*$" (downcase val)))))))
|
||||||
|
|
||||||
|
(defun z/comments-file-slug (file)
|
||||||
|
"Return page slug from #+SLUG: or fallback to filename base."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(org-mode)
|
||||||
|
(let* ((keywords (org-collect-keywords '("SLUG")))
|
||||||
|
(slug (cadr (assoc "SLUG" keywords))))
|
||||||
|
(if (and slug (string-match-p "\\S-" slug))
|
||||||
|
slug
|
||||||
|
(file-name-base file)))))
|
||||||
|
|
||||||
|
(defun z/comment-page-url (file)
|
||||||
|
"Return root-relative published HTML URL for source FILE."
|
||||||
|
(concat "/"
|
||||||
|
(file-name-sans-extension
|
||||||
|
(file-relative-name file z/site-root))
|
||||||
|
".html"))
|
||||||
|
|
||||||
|
(defun z/comment-page-title (file)
|
||||||
|
"Return exported page title for FILE, or the file basename."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file nil 0 4096)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t)
|
||||||
|
(match-string 1)
|
||||||
|
(file-name-base file))))
|
||||||
|
|
||||||
|
(defun z/comment-page-record (file)
|
||||||
|
"Return a JSON-ready comment page record for FILE."
|
||||||
|
`((slug . ,(z/comments-file-slug file))
|
||||||
|
(title . ,(z/comment-page-title file))
|
||||||
|
(url . ,(z/comment-page-url file))))
|
||||||
|
|
||||||
|
(defun z/generate-comment-pages-json ()
|
||||||
|
"Write a slug-to-page manifest for comment-enabled Org files."
|
||||||
|
(let* ((files (directory-files-recursively z/site-root "\\.org\\'"))
|
||||||
|
(records
|
||||||
|
(cl-loop for file in files
|
||||||
|
unless (or (string-prefix-p (site-path "output/") file)
|
||||||
|
(string-prefix-p (site-path "backups/") file)
|
||||||
|
(string-prefix-p (site-path ".org-timestamps/") file)
|
||||||
|
(string-prefix-p (site-path ".packages/") file))
|
||||||
|
when (z/comments-file-p file)
|
||||||
|
collect (z/comment-page-record file)))
|
||||||
|
(target (site-path "assets/content/comment-pages.json"))
|
||||||
|
(json-encoding-pretty-print t))
|
||||||
|
(make-directory (file-name-directory target) t)
|
||||||
|
(with-temp-file target
|
||||||
|
(insert (json-encode (vconcat records)))
|
||||||
|
(insert "\n"))
|
||||||
|
(z/log 'done "Wrote comment page manifest (%d pages)" (length records))))
|
||||||
|
|
||||||
|
(defun z/org-html-insert-comments-into-body (body backend info)
|
||||||
|
"Append a comments section to BODY for files that opt in."
|
||||||
|
(if (org-export-derived-backend-p backend 'html)
|
||||||
|
(let ((input-file (plist-get info :input-file)))
|
||||||
|
(if (and input-file (z/comments-file-p input-file))
|
||||||
|
(let* ((slug (z/comments-file-slug input-file))
|
||||||
|
(html (format
|
||||||
|
"\n<section id=\"comments\" class=\"comments\" data-slug=\"%s\">
|
||||||
|
<h2>Comments</h2>
|
||||||
|
<div id=\"comments-list\" class=\"comments-list\">
|
||||||
|
<noscript>Please enable JavaScript to view comments.</noscript>
|
||||||
|
</div>
|
||||||
|
<form id=\"comment-form\" class=\"comment-form\">
|
||||||
|
<label class=\"comment-author\">
|
||||||
|
<span>Name (optional)</span>
|
||||||
|
<input type=\"text\" name=\"author\"/>
|
||||||
|
</label>
|
||||||
|
<label class=\"comment-content\">
|
||||||
|
<span>Your comment</span>
|
||||||
|
<textarea name=\"content\" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button type=\"submit\">Post comment</button>
|
||||||
|
</form>
|
||||||
|
</section>\n"
|
||||||
|
slug)))
|
||||||
|
(concat body html))
|
||||||
|
body))
|
||||||
|
body))
|
||||||
|
|
||||||
|
(provide 'build-comments)
|
||||||
|
|
||||||
|
;;; build-comments.el ends here
|
||||||
102
lisp/build-core.el
Normal file
102
lisp/build-core.el
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
;;; build-core.el --- Build bootstrap and logging -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'package)
|
||||||
|
|
||||||
|
(defconst z/colour-reset "\033[0m")
|
||||||
|
(defconst z/colour-bold "\033[1m")
|
||||||
|
(defconst z/colour-dim "\033[2m")
|
||||||
|
(defconst z/colour-green "\033[32m")
|
||||||
|
(defconst z/colour-yellow "\033[33m")
|
||||||
|
(defconst z/colour-cyan "\033[36m")
|
||||||
|
(defconst z/colour-red "\033[31m")
|
||||||
|
(defconst z/colour-blue "\033[34m")
|
||||||
|
(defconst z/colour-magenta "\033[35m")
|
||||||
|
|
||||||
|
(defun z/log (level fmt &rest args)
|
||||||
|
"Emit a coloured build-log line.
|
||||||
|
LEVEL is one of: info warn error step metric done.
|
||||||
|
FMT and ARGS are passed to `format'."
|
||||||
|
(let* ((msg (apply #'format fmt args))
|
||||||
|
(prefix
|
||||||
|
(pcase level
|
||||||
|
('info (concat z/colour-cyan " INFO " z/colour-reset))
|
||||||
|
('warn (concat z/colour-yellow " WARN " z/colour-reset))
|
||||||
|
('error (concat z/colour-red " ERROR " z/colour-reset))
|
||||||
|
('step (concat z/colour-blue z/colour-bold " ---- " z/colour-reset))
|
||||||
|
('metric (concat z/colour-magenta " * " z/colour-reset))
|
||||||
|
('done (concat z/colour-green z/colour-bold " OK " z/colour-reset))
|
||||||
|
(_ " "))))
|
||||||
|
(message "%s%s" prefix msg)))
|
||||||
|
|
||||||
|
(defun z/log-separator (&optional label)
|
||||||
|
"Print a separator line, optionally with LABEL."
|
||||||
|
(if label
|
||||||
|
(message "%s%s-- %s %s%s"
|
||||||
|
z/colour-dim z/colour-bold label
|
||||||
|
(make-string (max 0 (- 60 (length label))) ?-)
|
||||||
|
z/colour-reset)
|
||||||
|
(message "%s%s%s"
|
||||||
|
z/colour-dim
|
||||||
|
(make-string 66 ?-)
|
||||||
|
z/colour-reset)))
|
||||||
|
|
||||||
|
(defmacro z/with-timing (label &rest body)
|
||||||
|
"Execute BODY, then log elapsed seconds under LABEL."
|
||||||
|
(declare (indent 1))
|
||||||
|
(let ((t0 (gensym "t0")))
|
||||||
|
`(let ((,t0 (float-time)))
|
||||||
|
(prog1 (progn ,@body)
|
||||||
|
(z/log 'metric "%s took %.2fs" ,label (- (float-time) ,t0))))))
|
||||||
|
|
||||||
|
(defun z/bootstrap-packages ()
|
||||||
|
"Set up Emacs packages required by the build."
|
||||||
|
(z/log-separator "Package setup")
|
||||||
|
(setq package-user-dir (site-path ".packages"))
|
||||||
|
(setq package-archives
|
||||||
|
'(("melpa" . "https://melpa.org/packages/")
|
||||||
|
("elpa" . "https://elpa.gnu.org/packages/")))
|
||||||
|
(package-initialize)
|
||||||
|
(z/with-timing "Package archive refresh"
|
||||||
|
(unless package-archive-contents
|
||||||
|
(z/log 'info "Refreshing package archive contents...")
|
||||||
|
(condition-case err
|
||||||
|
(package-refresh-contents)
|
||||||
|
(error
|
||||||
|
(z/log 'warn "Could not refresh archives: %s" (error-message-string err))))))
|
||||||
|
(z/with-timing "htmlize install"
|
||||||
|
(unless (package-installed-p 'htmlize)
|
||||||
|
(z/log 'info "Installing htmlize...")
|
||||||
|
(condition-case err
|
||||||
|
(package-install 'htmlize)
|
||||||
|
(error
|
||||||
|
(z/log 'error "htmlize install failed: %s" (error-message-string err))
|
||||||
|
(error "Cannot continue without htmlize")))))
|
||||||
|
(require 'htmlize))
|
||||||
|
|
||||||
|
(defun z/configure-org ()
|
||||||
|
"Load Org export libraries and configure shared Org settings."
|
||||||
|
(require 'ox-publish)
|
||||||
|
(require 'cl-lib)
|
||||||
|
(require 'json)
|
||||||
|
(require 'org)
|
||||||
|
(require 'ox)
|
||||||
|
(require 'ox-html)
|
||||||
|
(require 'ox-md)
|
||||||
|
(setq org-publish-timestamp-directory (site-path ".org-timestamps/"))
|
||||||
|
(setq org-html-htmlize-output-type 'css))
|
||||||
|
|
||||||
|
(defun z/require-local (library)
|
||||||
|
"Require local build LIBRARY with consistent error reporting."
|
||||||
|
(condition-case err
|
||||||
|
(progn
|
||||||
|
(require library)
|
||||||
|
(z/log 'done "Loaded %s" library))
|
||||||
|
(error
|
||||||
|
(z/log 'error "Failed to load %s: %s" library (error-message-string err))
|
||||||
|
(error "Aborting: required library %s missing" library))))
|
||||||
|
|
||||||
|
(provide 'build-core)
|
||||||
|
|
||||||
|
;;; build-core.el ends here
|
||||||
113
lisp/build-html.el
Normal file
113
lisp/build-html.el
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
;;; build-html.el --- HTML export customisation -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'org)
|
||||||
|
(require 'ox-html)
|
||||||
|
(require 'subr-x)
|
||||||
|
(require 'tags)
|
||||||
|
(require 'sidenotes)
|
||||||
|
(require 'build-comments)
|
||||||
|
|
||||||
|
(setq org-export-global-macros
|
||||||
|
(append
|
||||||
|
org-export-global-macros
|
||||||
|
'(("sidenote"
|
||||||
|
. "@@html:<label for=\"sn$1\" class=\"margin-toggle sidenote-number\"></label>\
|
||||||
|
<input type=\"checkbox\" id=\"sn$1\" class=\"margin-toggle\"/>\
|
||||||
|
<span class=\"sidenote\">$2</span>@@")
|
||||||
|
("epigraph"
|
||||||
|
. "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2</footer></blockquote></div>@@")
|
||||||
|
("epigraph_single"
|
||||||
|
. "@@html:<div class=\"epigraph\"><blockquote>$1</blockquote></div>@@")
|
||||||
|
("epigraph3"
|
||||||
|
. "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2, <cite>$3</cite></footer></blockquote></div>@@")
|
||||||
|
("kbd"
|
||||||
|
. "@@html:<kbd>$1</kbd>@@@@latex:\\texttt{$1}@@")
|
||||||
|
("margimg"
|
||||||
|
. "@@html:<aside class=\"marginnote\"><figure class=\"mn-fig\">\
|
||||||
|
<img src=\"$1\" alt=\"$2\" class=\"mn-img\" loading=\"lazy\" decoding=\"async\"/>$3\
|
||||||
|
</figure></aside>@@")
|
||||||
|
("countdown"
|
||||||
|
. "@@html:<time class=\"countdown\" datetime=\"$1\" data-label=\"$2\"></time>@@"))))
|
||||||
|
|
||||||
|
(defvar z/preamble
|
||||||
|
"<div class=\"banner-header\" role=\"banner\">
|
||||||
|
<a class=\"site-brand\" href=\"/\" aria-label=\"Home\">
|
||||||
|
<img src=\"/assets/images/gr.png\" alt=\"\" class=\"banner-logo\" />
|
||||||
|
<span class=\"site-brand__text\">zxh</span>
|
||||||
|
</a>
|
||||||
|
<nav class=\"site-nav\" aria-label=\"Primary\">
|
||||||
|
<a href=\"/\">Home</a>
|
||||||
|
<a href=\"/blogs/blogs-list.html\">Blogs</a>
|
||||||
|
<a href=\"/posts/career/career-list.html\">Career</a>
|
||||||
|
<a href=\"/play/play.html\">Play</a>
|
||||||
|
<a href=\"https://zone.zainezq.com\">Dashboard</a>
|
||||||
|
</nav>
|
||||||
|
<div class=\"site-actions\">
|
||||||
|
<label class=\"site-search\" for=\"search-input\">
|
||||||
|
<span class=\"visually-hidden\">Search notes</span>
|
||||||
|
<input type=\"search\" id=\"search-input\" placeholder=\"Search notes\" aria-label=\"Search notes\" />
|
||||||
|
</label>
|
||||||
|
<button id=\"search-btn\" aria-label=\"Search\" type=\"button\">Search</button>
|
||||||
|
<button class=\"theme-toggle\" id=\"theme-toggle\" type=\"button\" aria-label=\"Switch theme\">Theme</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id=\"updated\">Updated: %C</div>
|
||||||
|
")
|
||||||
|
|
||||||
|
(defvar z/postamble
|
||||||
|
"<footer>
|
||||||
|
<div class=\"copyright-container\">
|
||||||
|
<div class=\"copyright\">
|
||||||
|
Copyright © 2022-2026 Zaine Qayyum. All rights reserved unless otherwise noted.</div></div>
|
||||||
|
<div class=\"generated\">
|
||||||
|
Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
||||||
|
<a href=\"https://www.gnu.org\">GNU</a>/<a href=\"https://www.kernel.org/\">Linux</a>
|
||||||
|
</div>
|
||||||
|
</footer>")
|
||||||
|
|
||||||
|
(defun z/org-html-src-block-mermaid (orig-fun src-block contents info)
|
||||||
|
"Export Mermaid source blocks as Mermaid divs."
|
||||||
|
(let ((lang (org-element-property :language src-block)))
|
||||||
|
(if (string= lang "mermaid")
|
||||||
|
(let ((code (org-remove-indentation
|
||||||
|
(org-element-property :value src-block))))
|
||||||
|
(format "<div class=\"mermaid\">\n%s\n</div>" code))
|
||||||
|
(funcall orig-fun src-block contents info))))
|
||||||
|
|
||||||
|
(advice-add 'org-html-src-block :around #'z/org-html-src-block-mermaid)
|
||||||
|
|
||||||
|
(defun z/org-html-add-body-classes (output backend info)
|
||||||
|
"Add layout-related classes to <body> based on file metadata."
|
||||||
|
(if (org-export-derived-backend-p backend 'html)
|
||||||
|
(let* ((input-file (plist-get info :input-file))
|
||||||
|
(classes
|
||||||
|
(delq nil
|
||||||
|
(list
|
||||||
|
(when (and input-file (z/no-sidenotes-file-p input-file))
|
||||||
|
"no-sidenotes")))))
|
||||||
|
(if classes
|
||||||
|
(replace-regexp-in-string
|
||||||
|
"<body\\([^>]*\\)>"
|
||||||
|
(format "<body\\1 class=\"%s\">"
|
||||||
|
(string-join classes " "))
|
||||||
|
output)
|
||||||
|
output))
|
||||||
|
output))
|
||||||
|
|
||||||
|
(add-to-list 'org-export-filter-final-output-functions
|
||||||
|
#'z/org-html-add-body-classes)
|
||||||
|
(add-to-list 'org-export-filter-body-functions
|
||||||
|
#'z/org-html-insert-comments-into-body)
|
||||||
|
|
||||||
|
(org-export-define-derived-backend 'z-html 'html
|
||||||
|
:filters-alist '((:filter-final-output . z/insert-filetags-after-title)))
|
||||||
|
|
||||||
|
(defun z/z-publish-to-html (plist filename pub-dir)
|
||||||
|
"Publish FILENAME to HTML using the z-html backend."
|
||||||
|
(org-publish-org-to 'z-html filename ".html" plist pub-dir))
|
||||||
|
|
||||||
|
(provide 'build-html)
|
||||||
|
|
||||||
|
;;; build-html.el ends here
|
||||||
89
lisp/build-lima.el
Normal file
89
lisp/build-lima.el
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
;;; build-lima.el --- Lima publishing adapter -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'org)
|
||||||
|
(require 'ox-publish)
|
||||||
|
|
||||||
|
(defun z/lima-entry-title (file fallback)
|
||||||
|
"Return FILE's Org title, or FALLBACK when no title is present."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file nil 0 4096)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t)
|
||||||
|
(match-string 1)
|
||||||
|
fallback)))
|
||||||
|
|
||||||
|
(defun z/lima-sitemap-format-entry (entry _style project)
|
||||||
|
"Format a sitemap ENTRY for Lima; skip directories."
|
||||||
|
(let* ((file (if (listp entry) (car entry) entry))
|
||||||
|
(base-dir (file-name-as-directory
|
||||||
|
(org-publish-property :base-directory project)))
|
||||||
|
(abs (if (file-name-absolute-p file)
|
||||||
|
file
|
||||||
|
(expand-file-name file base-dir))))
|
||||||
|
(unless (file-directory-p abs)
|
||||||
|
(let* ((rel (file-relative-name abs base-dir))
|
||||||
|
(rel-noext (file-name-sans-extension rel))
|
||||||
|
(title (z/lima-entry-title abs (file-name-nondirectory rel-noext))))
|
||||||
|
(format "[[file:%s.html][%s]]"
|
||||||
|
rel-noext
|
||||||
|
title)))))
|
||||||
|
|
||||||
|
(defun z/copy-neighbor-attachments (source-dir pub-dir)
|
||||||
|
"Copy sibling .attachments.* directories from SOURCE-DIR into PUB-DIR."
|
||||||
|
(dolist (d (directory-files source-dir t "^\\.attachments\\..+"))
|
||||||
|
(when (file-directory-p d)
|
||||||
|
(let ((target (expand-file-name (file-name-nondirectory d) pub-dir)))
|
||||||
|
(make-directory target t)
|
||||||
|
(copy-directory d target t t t)))))
|
||||||
|
|
||||||
|
(defun z/markdown-h1-title (filename fallback)
|
||||||
|
"Return the first Markdown H1 title in FILENAME, or FALLBACK."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents filename nil 0 4096)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(if (re-search-forward "^#+ +\\(.+?\\)\\s-*$" nil t)
|
||||||
|
(match-string 1)
|
||||||
|
fallback)))
|
||||||
|
|
||||||
|
(defun z/publish-lima-file (plist filename pub-dir)
|
||||||
|
"Publish an Org or Markdown file from the lima directory."
|
||||||
|
(let* ((ext (downcase (or (file-name-extension filename) "")))
|
||||||
|
(base (file-name-base filename))
|
||||||
|
(src-dir (file-name-directory filename)))
|
||||||
|
(cond
|
||||||
|
((string= ext "org")
|
||||||
|
(z/copy-neighbor-attachments src-dir pub-dir)
|
||||||
|
(org-publish-org-to 'z-html filename ".html" plist pub-dir))
|
||||||
|
((string= ext "md")
|
||||||
|
(let* ((tmp-dir (make-temp-file "lima-build-" t))
|
||||||
|
(temp-org (expand-file-name (concat base ".org") tmp-dir)))
|
||||||
|
(let ((rc (call-process "pandoc" nil nil nil
|
||||||
|
filename "-f" "markdown" "-t" "org" "-o" temp-org)))
|
||||||
|
(unless (zerop rc)
|
||||||
|
(z/log 'error "pandoc failed (rc=%d) for %s" rc filename)
|
||||||
|
(error "pandoc conversion failed for %s" filename)))
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents temp-org)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(insert (format "#+TITLE: %s\n#+OPTIONS: num:nil\n#+DATE: %s\n#+COMMENTS: t\n#+SLUG: %s\n\n"
|
||||||
|
(z/markdown-h1-title filename base)
|
||||||
|
(format-time-string "<%Y-%m-%d %a %H:%M>")
|
||||||
|
base))
|
||||||
|
(write-region (point-min) (point-max) temp-org))
|
||||||
|
(z/copy-neighbor-attachments src-dir pub-dir)
|
||||||
|
(let ((output-file (expand-file-name (concat base ".html") pub-dir)))
|
||||||
|
(org-publish-org-to 'z-html temp-org ".html" plist pub-dir)
|
||||||
|
(let ((generated (expand-file-name
|
||||||
|
(concat (file-name-base temp-org) ".html")
|
||||||
|
pub-dir)))
|
||||||
|
(when (and (file-exists-p generated)
|
||||||
|
(not (string-equal generated output-file)))
|
||||||
|
(rename-file generated output-file t))))))
|
||||||
|
(t
|
||||||
|
(org-publish-attachment plist filename pub-dir)))))
|
||||||
|
|
||||||
|
(provide 'build-lima)
|
||||||
|
|
||||||
|
;;; build-lima.el ends here
|
||||||
139
lisp/build-projects.el
Normal file
139
lisp/build-projects.el
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
;;; build-projects.el --- org-publish project configuration -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'ox-publish)
|
||||||
|
|
||||||
|
(defun z/org-main-prep (_project)
|
||||||
|
"Pre-publish hook: regenerate recently-updated.org."
|
||||||
|
(z/log 'step "Generating recently-updated.org...")
|
||||||
|
(z/with-timing "recently-updated generation"
|
||||||
|
(z/generate-recently-updated-org 26)))
|
||||||
|
|
||||||
|
(defun z/configure-publish-projects ()
|
||||||
|
"Install `org-publish-project-alist' for the site."
|
||||||
|
(setq org-publish-project-alist
|
||||||
|
`(("org-main"
|
||||||
|
:recursive t
|
||||||
|
:base-directory ,(site-path "")
|
||||||
|
:publishing-function z/z-publish-to-html
|
||||||
|
:publishing-directory ,(output-path "")
|
||||||
|
:base-extension "org"
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-filename "sitemap.org"
|
||||||
|
:sitemap-title "Sitemap"
|
||||||
|
:sitemap-function z/main-sitemap
|
||||||
|
:sitemap-sort-files chronologically
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:html-head ,z/shared-head
|
||||||
|
:preparation-function z/org-main-prep)
|
||||||
|
|
||||||
|
("org-assets"
|
||||||
|
:base-directory ,(site-path "assets/")
|
||||||
|
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|woff\\|woff2\\|ttf"
|
||||||
|
:publishing-directory ,(output-path "assets/")
|
||||||
|
:recursive t
|
||||||
|
:publishing-function org-publish-attachment)
|
||||||
|
|
||||||
|
("org-categories-sitemap"
|
||||||
|
:recursive t
|
||||||
|
:base-directory ,(site-path "home/")
|
||||||
|
:publishing-directory ,(output-path "home/")
|
||||||
|
:base-extension "org"
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-filename "categories.org"
|
||||||
|
:sitemap-title "Categories"
|
||||||
|
:sitemap-function z/categories-sitemap
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:html-head ,z/shared-head)
|
||||||
|
|
||||||
|
("org-posts"
|
||||||
|
:base-directory ,(site-path "posts/")
|
||||||
|
:publishing-directory ,(output-path "posts/")
|
||||||
|
:recursive t
|
||||||
|
:base-extension "org"
|
||||||
|
:publishing-function z/z-publish-to-html
|
||||||
|
:with-author nil
|
||||||
|
:with-creator nil
|
||||||
|
:html-validation-link nil
|
||||||
|
:with-toc t
|
||||||
|
:section-numbers t
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-filename "posts-list.org"
|
||||||
|
:sitemap-title "Posts List"
|
||||||
|
:sitemap-style list
|
||||||
|
:sitemap-function z/posts-sitemap
|
||||||
|
:sitemap-sort-files anti-chronologically
|
||||||
|
:html-head ,z/shared-head)
|
||||||
|
|
||||||
|
("org-blogs"
|
||||||
|
:base-directory ,(site-path "blogs/")
|
||||||
|
:publishing-directory ,(output-path "blogs/")
|
||||||
|
:recursive t
|
||||||
|
:base-extension "org"
|
||||||
|
:publishing-function z/z-publish-to-html
|
||||||
|
:with-author nil
|
||||||
|
:with-creator nil
|
||||||
|
:html-validation-link nil
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-filename "blogs-list.org"
|
||||||
|
:sitemap-title "Blogs List"
|
||||||
|
:sitemap-style list
|
||||||
|
:sitemap-function z/blogs-grouped-sitemap
|
||||||
|
:sitemap-sort-files anti-chronologically
|
||||||
|
:html-head ,z/shared-head)
|
||||||
|
|
||||||
|
("org-career"
|
||||||
|
:base-directory ,(site-path "posts/career/")
|
||||||
|
:publishing-directory ,(output-path "posts/career/")
|
||||||
|
:recursive t
|
||||||
|
:base-extension "org"
|
||||||
|
:publishing-function z/z-publish-to-html
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-filename "career-list.org"
|
||||||
|
:sitemap-title "Career List"
|
||||||
|
:sitemap-style list
|
||||||
|
:sitemap-function z/career-sitemap
|
||||||
|
:sitemap-sort-files anti-chronologically
|
||||||
|
:html-head ,z/shared-head)
|
||||||
|
|
||||||
|
("org-tags"
|
||||||
|
:base-directory ,(site-path "tags/")
|
||||||
|
:publishing-directory ,(output-path "tags/")
|
||||||
|
:recursive t
|
||||||
|
:base-extension "org"
|
||||||
|
:publishing-function org-html-publish-to-html
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:html-head ,z/shared-head)
|
||||||
|
|
||||||
|
("org-lima"
|
||||||
|
:base-directory ,(site-path "lima/")
|
||||||
|
:publishing-directory ,(output-path "lima/")
|
||||||
|
:recursive t
|
||||||
|
:base-extension "org\\|md"
|
||||||
|
:publishing-function z/publish-lima-file
|
||||||
|
:with-author nil
|
||||||
|
:with-creator nil
|
||||||
|
:html-validation-link nil
|
||||||
|
:html-preamble ,z/preamble
|
||||||
|
:html-postamble ,z/postamble
|
||||||
|
:sitemap-filename "lima-list.org"
|
||||||
|
:auto-sitemap t
|
||||||
|
:sitemap-title "Lima"
|
||||||
|
:sitemap-style list
|
||||||
|
:sitemap-sort-files anti-chronologically
|
||||||
|
:sitemap-format-entry z/lima-sitemap-format-entry
|
||||||
|
:html-head ,z/shared-head))))
|
||||||
|
|
||||||
|
(provide 'build-projects)
|
||||||
|
|
||||||
|
;;; build-projects.el ends here
|
||||||
120
lisp/build-runner.el
Normal file
120
lisp/build-runner.el
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
;;; build-runner.el --- Build orchestration -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'ox-publish)
|
||||||
|
|
||||||
|
(defun z/wipe-build-dirs ()
|
||||||
|
"Delete output/ and .org-timestamps/ together."
|
||||||
|
(dolist (dir (list z/output-root
|
||||||
|
(site-path ".org-timestamps/")))
|
||||||
|
(if (file-directory-p dir)
|
||||||
|
(progn
|
||||||
|
(z/with-timing (format "delete %s"
|
||||||
|
(file-name-nondirectory
|
||||||
|
(directory-file-name dir)))
|
||||||
|
(delete-directory dir t))
|
||||||
|
(z/log 'done "Deleted %s" dir))
|
||||||
|
(z/log 'info "Already absent: %s" dir))))
|
||||||
|
|
||||||
|
(defun z/output-integrity-ok-p ()
|
||||||
|
"Return t if critical output paths all exist."
|
||||||
|
(let ((missing '()))
|
||||||
|
(unless (file-directory-p (output-path "assets/"))
|
||||||
|
(push "output/assets/" missing))
|
||||||
|
(unless (or (file-exists-p (output-path "index.html"))
|
||||||
|
(file-exists-p (output-path "sitemap.html")))
|
||||||
|
(push "output/index.html (or sitemap.html)" missing))
|
||||||
|
(dolist (m missing)
|
||||||
|
(z/log 'warn "Integrity check: missing %s" m))
|
||||||
|
(null missing)))
|
||||||
|
|
||||||
|
(defun z/prepare-output-cache ()
|
||||||
|
"Keep org-publish output and timestamp cache in sync."
|
||||||
|
(z/log-separator "Pre-build")
|
||||||
|
(z/log 'info "Site source root : %s" z/site-root)
|
||||||
|
(z/log 'info "Site output root : %s" z/output-root)
|
||||||
|
(cond
|
||||||
|
((getenv "SITE_CLEAN")
|
||||||
|
(z/log 'warn "SITE_CLEAN=1 - wiping output/ and .org-timestamps/")
|
||||||
|
(z/wipe-build-dirs)
|
||||||
|
(setenv "SITE_FORCE" "1"))
|
||||||
|
((not (z/output-integrity-ok-p))
|
||||||
|
(z/log 'warn "Output integrity check failed - timestamp cache is stale")
|
||||||
|
(z/log 'warn "Clearing .org-timestamps/ and forcing full republish")
|
||||||
|
(let ((ts-dir (site-path ".org-timestamps/")))
|
||||||
|
(when (file-directory-p ts-dir)
|
||||||
|
(z/with-timing "delete .org-timestamps"
|
||||||
|
(delete-directory ts-dir t))
|
||||||
|
(z/log 'done "Deleted .org-timestamps/")))
|
||||||
|
(setenv "SITE_FORCE" "1"))
|
||||||
|
(t
|
||||||
|
(z/log 'info "Incremental build - output/ looks complete"))))
|
||||||
|
|
||||||
|
(defun z/generate-build-inputs ()
|
||||||
|
"Generate source-side data files required before publishing."
|
||||||
|
(z/log-separator "Generated inputs")
|
||||||
|
(z/with-timing "tag page generation"
|
||||||
|
(condition-case err
|
||||||
|
(let ((n (z/write-tag-pages)))
|
||||||
|
(z/log 'done "Tag pages written%s"
|
||||||
|
(if (numberp n) (format " (%d tags)" n) "")))
|
||||||
|
(error
|
||||||
|
(z/log 'error "Tag generation failed: %s" (error-message-string err))
|
||||||
|
(error "Aborting build: tag generation error"))))
|
||||||
|
(z/with-timing "comment page manifest"
|
||||||
|
(condition-case err
|
||||||
|
(z/generate-comment-pages-json)
|
||||||
|
(error
|
||||||
|
(z/log 'error "Comment page manifest failed: %s" (error-message-string err))
|
||||||
|
(error "Aborting build: comment page manifest error")))))
|
||||||
|
|
||||||
|
(defun z/publish-site ()
|
||||||
|
"Publish the configured site."
|
||||||
|
(z/log-separator "Publishing")
|
||||||
|
(if (getenv "SITE_DRY")
|
||||||
|
(z/log 'warn "SITE_DRY=1 - skipping org-publish-all (dry run)")
|
||||||
|
(let ((force (not (not (getenv "SITE_FORCE")))))
|
||||||
|
(when force
|
||||||
|
(z/log 'info "SITE_FORCE=1 - forcing republish of all files"))
|
||||||
|
(condition-case err
|
||||||
|
(progn
|
||||||
|
(z/with-timing "org-publish-all"
|
||||||
|
(org-publish-all force))
|
||||||
|
(z/with-timing "asset sync"
|
||||||
|
(z/sync-assets-to-output)))
|
||||||
|
(error
|
||||||
|
(z/log 'error "org-publish-all failed: %s" (error-message-string err))
|
||||||
|
(error "Build failed"))))))
|
||||||
|
|
||||||
|
(defun z/log-build-summary ()
|
||||||
|
"Log a summary of output artifacts."
|
||||||
|
(z/log-separator "Build summary")
|
||||||
|
(let* ((elapsed (- (float-time) z/build-start-time))
|
||||||
|
(out-dir z/output-root)
|
||||||
|
(html-files (when (file-directory-p out-dir)
|
||||||
|
(length (directory-files-recursively out-dir "\\.html\\'"))))
|
||||||
|
(css-files (when (file-directory-p out-dir)
|
||||||
|
(length (directory-files-recursively out-dir "\\.css\\'"))))
|
||||||
|
(js-files (when (file-directory-p out-dir)
|
||||||
|
(length (directory-files-recursively out-dir "\\.js\\'"))))
|
||||||
|
(tag-files (let ((td (output-path "tags/")))
|
||||||
|
(when (file-directory-p td)
|
||||||
|
(length (directory-files td nil "\\.html\\'"))))))
|
||||||
|
(z/log 'metric "Total elapsed : %.2fs" elapsed)
|
||||||
|
(z/log 'metric "HTML files output: %d" (or html-files 0))
|
||||||
|
(z/log 'metric "CSS files output: %d" (or css-files 0))
|
||||||
|
(z/log 'metric "JS files output: %d" (or js-files 0))
|
||||||
|
(z/log 'metric "Tag pages output: %d" (or tag-files 0))
|
||||||
|
(z/log 'done "Build complete")))
|
||||||
|
|
||||||
|
(defun z/run-build ()
|
||||||
|
"Run the full site build."
|
||||||
|
(z/prepare-output-cache)
|
||||||
|
(z/generate-build-inputs)
|
||||||
|
(z/publish-site)
|
||||||
|
(z/log-build-summary))
|
||||||
|
|
||||||
|
(provide 'build-runner)
|
||||||
|
|
||||||
|
;;; build-runner.el ends here
|
||||||
@@ -20,8 +20,7 @@
|
|||||||
(defconst z/recent-omit-names
|
(defconst z/recent-omit-names
|
||||||
'("sitemap.org"
|
'("sitemap.org"
|
||||||
"categories.org"
|
"categories.org"
|
||||||
"recently-updated.org"
|
"recently-updated.org")
|
||||||
"wip.org")
|
|
||||||
"Org files that are always excluded from the recently-updated list.")
|
"Org files that are always excluded from the recently-updated list.")
|
||||||
|
|
||||||
(defun z/recent-omit-org-p (rel-org)
|
(defun z/recent-omit-org-p (rel-org)
|
||||||
|
|||||||
@@ -251,17 +251,6 @@ CATEGORIES-REL — relative path to categories.html from this project root"
|
|||||||
(nth 2 entry))))))))
|
(nth 2 entry))))))))
|
||||||
output)))
|
output)))
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; Books
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/books-sitemap (title list)
|
|
||||||
"Flat sitemap for org-books."
|
|
||||||
(z/sitemap--flat-list title list
|
|
||||||
(site-path "books/")
|
|
||||||
"Book Notes:"
|
|
||||||
"../home/categories.html"))
|
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
;; ─────────────────────────────────────────────────────────────────────────────
|
||||||
;; Year-scoped sitemaps (generic helper)
|
;; Year-scoped sitemaps (generic helper)
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
;; ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -362,59 +351,6 @@ CATEGORIES-REL is the relative href to categories.html."
|
|||||||
"\n")
|
"\n")
|
||||||
"_No tags found yet._")))))
|
"_No tags found yet._")))))
|
||||||
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
;; WIP sitemap
|
|
||||||
;; ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun z/wip-file-p (file)
|
|
||||||
"Return non-nil if FILE is marked as work-in-progress.
|
|
||||||
|
|
||||||
A file is WIP when any of the following is true:
|
|
||||||
- It has a #+WIP: keyword with a non-empty value.
|
|
||||||
- Its FILETAGS contain :WIP:.
|
|
||||||
- Any top-level heading contains \"WIP\" (case-insensitive)."
|
|
||||||
(when (file-exists-p file)
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert-file-contents file)
|
|
||||||
(org-mode)
|
|
||||||
(let* ((case-fold-search t)
|
|
||||||
(keywords (org-collect-keywords '("WIP" "FILETAGS")))
|
|
||||||
(wip (cadr (assoc "WIP" keywords)))
|
|
||||||
(filetags (cadr (assoc "FILETAGS" keywords))))
|
|
||||||
(or
|
|
||||||
(and wip (string-match-p "\\S-" wip))
|
|
||||||
(and filetags (string-match-p ":WIP:" (concat ":" filetags ":")))
|
|
||||||
(save-excursion
|
|
||||||
(goto-char (point-min))
|
|
||||||
(re-search-forward "^\\*+ .*WIP.*" nil t)))))))
|
|
||||||
|
|
||||||
(defun z/wip-sitemap (title list)
|
|
||||||
"Sitemap listing only entries whose source files are marked WIP."
|
|
||||||
(let ((items
|
|
||||||
(delq nil
|
|
||||||
(mapcar
|
|
||||||
(lambda (entry)
|
|
||||||
(when (consp entry)
|
|
||||||
(let* ((link (car entry))
|
|
||||||
(filename (if (string-match "\\[\\[file:\\([^]]+\\)\\]" link)
|
|
||||||
(match-string 1 link)
|
|
||||||
link))
|
|
||||||
(full-path (expand-file-name filename z/site-root)))
|
|
||||||
(when (z/wip-file-p full-path)
|
|
||||||
(let* ((date (org-publish-find-date full-path org-publish-project-alist))
|
|
||||||
(date-str (if date
|
|
||||||
(format-time-string "%d-%m-%Y %H:%M" date)
|
|
||||||
"")))
|
|
||||||
(format "- %s @@html:<span class=\"post-date\">%s</span>@@"
|
|
||||||
link date-str))))))
|
|
||||||
(cdr list)))))
|
|
||||||
(concat "#+TITLE: " title "\n"
|
|
||||||
"#+OPTIONS: toc:nil num:nil\n\n"
|
|
||||||
"* Work in progress\n"
|
|
||||||
(if items
|
|
||||||
(mapconcat #'identity items "\n")
|
|
||||||
"No items currently marked as WIP.\n"))))
|
|
||||||
|
|
||||||
(provide 'sitemaps)
|
(provide 'sitemaps)
|
||||||
|
|
||||||
;;; sitemaps.el ends here
|
;;; sitemaps.el ends here
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
(defconst z/generated-org-names
|
(defconst z/generated-org-names
|
||||||
'("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org"
|
'("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org"
|
||||||
"recently-updated.org" "wip.org")
|
"recently-updated.org")
|
||||||
"Generated Org files that should never be indexed for tags.")
|
"Generated Org files that should never be indexed for tags.")
|
||||||
|
|
||||||
(defun z/gather-tag-index ()
|
(defun z/gather-tag-index ()
|
||||||
|
|||||||
90
build-site-tests.el → lisp/tests/build-site-tests.el
Executable file → Normal file
90
build-site-tests.el → lisp/tests/build-site-tests.el
Executable file → Normal file
@@ -1,15 +1,49 @@
|
|||||||
;;; build-site-tests.el --- ERT unit tests for build-site.el -*- lexical-binding: t; -*-
|
;;; build-site-tests.el --- ERT unit tests for build modules -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
;; Run with:
|
;; Run with:
|
||||||
;; emacs -Q --batch -l build-site.el -l build-site-tests.el -f ert-run-tests-batch-and-exit
|
;; emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
|
||||||
;; Or interactively:
|
;; Or interactively:
|
||||||
;; M-x load-file RET build-site-tests.el RET
|
;; M-x load-file RET lisp/tests/build-site-tests.el RET
|
||||||
;; M-x ert RET t RET
|
;; M-x ert RET t RET
|
||||||
|
|
||||||
;;; Code:
|
;;; Code:
|
||||||
|
|
||||||
|
(defconst z/test-root
|
||||||
|
(file-name-directory
|
||||||
|
(directory-file-name
|
||||||
|
(file-name-directory
|
||||||
|
(directory-file-name
|
||||||
|
(file-name-directory (or load-file-name buffer-file-name)))))))
|
||||||
|
|
||||||
|
(defconst z/build-start-time (float-time))
|
||||||
|
(defconst z/site-root (file-name-as-directory z/test-root))
|
||||||
|
(defvaralias 'site-root 'z/site-root)
|
||||||
|
(defconst z/output-root (file-name-as-directory (expand-file-name "output/" z/site-root)))
|
||||||
|
|
||||||
|
(defun site-path (path)
|
||||||
|
"Expand PATH relative to test `z/site-root'."
|
||||||
|
(expand-file-name path z/site-root))
|
||||||
|
|
||||||
|
(defun output-path (path)
|
||||||
|
"Expand PATH relative to test `z/output-root'."
|
||||||
|
(expand-file-name path z/output-root))
|
||||||
|
|
||||||
|
(add-to-list 'load-path (site-path "lisp"))
|
||||||
|
|
||||||
(require 'ert)
|
(require 'ert)
|
||||||
|
(require 'cl-lib)
|
||||||
(require 'org)
|
(require 'org)
|
||||||
|
(require 'build-core)
|
||||||
|
|
||||||
|
(z/configure-org)
|
||||||
|
(require 'recently-updated)
|
||||||
|
(require 'tags)
|
||||||
|
(require 'sidenotes)
|
||||||
|
(require 'sitemaps)
|
||||||
|
(require 'build-assets)
|
||||||
|
(require 'build-comments)
|
||||||
|
(require 'build-html)
|
||||||
|
(require 'build-lima)
|
||||||
|
|
||||||
;; ── Helpers ──────────────────────────────────────────────────────────────────
|
;; ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -110,15 +144,11 @@
|
|||||||
(should (string= "<div>body</div>" result)))))
|
(should (string= "<div>body</div>" result)))))
|
||||||
|
|
||||||
(ert-deftest test/insert-comments-ignores-non-html-backends ()
|
(ert-deftest test/insert-comments-ignores-non-html-backends ()
|
||||||
"For non-HTML backends the function currently returns nil (known bug:
|
"For non-HTML backends the body should pass through unchanged."
|
||||||
`when' has no else branch so the body string is not passed through).
|
|
||||||
Fix: change `when' to `if' with body as the else clause.
|
|
||||||
This test documents current behaviour; flip the should once fixed."
|
|
||||||
(with-temp-org-file "#+TITLE: Test\n#+COMMENTS: t\n\nBody.\n"
|
(with-temp-org-file "#+TITLE: Test\n#+COMMENTS: t\n\nBody.\n"
|
||||||
(let* ((info (list :input-file temp-file))
|
(let* ((info (list :input-file temp-file))
|
||||||
(result (z/org-html-insert-comments-into-body "<div>body</div>" 'latex info)))
|
(result (z/org-html-insert-comments-into-body "<div>body</div>" 'latex info)))
|
||||||
;; BUG: should be (should (string= "<div>body</div>" result))
|
(should (string= "<div>body</div>" result)))))
|
||||||
(should (null result)))))
|
|
||||||
|
|
||||||
(ert-deftest test/insert-comments-handles-nil-input-file ()
|
(ert-deftest test/insert-comments-handles-nil-input-file ()
|
||||||
"Should return body unchanged when :input-file is nil."
|
"Should return body unchanged when :input-file is nil."
|
||||||
@@ -201,62 +231,34 @@ This test documents current behaviour; flip the should once fixed."
|
|||||||
(delete-directory pub-dir t))))
|
(delete-directory pub-dir t))))
|
||||||
|
|
||||||
;; ── z/org-html-add-body-classes ──────────────────────────────────────────────
|
;; ── z/org-html-add-body-classes ──────────────────────────────────────────────
|
||||||
;; These tests stub out z/wip-file-p and z/no-sidenotes-file-p so they run
|
;; These tests stub out z/no-sidenotes-file-p so they run without depending on
|
||||||
;; without the full site infrastructure loaded.
|
;; full site metadata.
|
||||||
|
|
||||||
(ert-deftest test/add-body-classes-adds-wip-class ()
|
|
||||||
"Should add class=\"wip\" to <body> when z/wip-file-p is t."
|
|
||||||
(with-temp-org-file "#+TITLE: WIP page\n\nContent.\n"
|
|
||||||
(cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t))
|
|
||||||
((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil)))
|
|
||||||
(let* ((info (list :input-file temp-file))
|
|
||||||
(output "<html><body><p>hello</p></body></html>")
|
|
||||||
(result (z/org-html-add-body-classes output 'html info)))
|
|
||||||
(should (string-match-p "class=\"wip\"" result))))))
|
|
||||||
|
|
||||||
(ert-deftest test/add-body-classes-adds-no-sidenotes-class ()
|
(ert-deftest test/add-body-classes-adds-no-sidenotes-class ()
|
||||||
"Should add class=\"no-sidenotes\" when z/no-sidenotes-file-p is t."
|
"Should add class=\"no-sidenotes\" when z/no-sidenotes-file-p is t."
|
||||||
(with-temp-org-file "#+TITLE: No sidenotes\n\nContent.\n"
|
(with-temp-org-file "#+TITLE: No sidenotes\n\nContent.\n"
|
||||||
(cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) nil))
|
(cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t)))
|
||||||
((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t)))
|
|
||||||
(let* ((info (list :input-file temp-file))
|
(let* ((info (list :input-file temp-file))
|
||||||
(output "<html><body><p>hello</p></body></html>")
|
(output "<html><body><p>hello</p></body></html>")
|
||||||
(result (z/org-html-add-body-classes output 'html info)))
|
(result (z/org-html-add-body-classes output 'html info)))
|
||||||
(should (string-match-p "class=\"no-sidenotes\"" result))))))
|
(should (string-match-p "class=\"no-sidenotes\"" result))))))
|
||||||
|
|
||||||
(ert-deftest test/add-body-classes-combines-multiple-classes ()
|
|
||||||
"Both wip and no-sidenotes classes should appear when both predicates are t."
|
|
||||||
(with-temp-org-file "#+TITLE: Both\n\nContent.\n"
|
|
||||||
(cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t))
|
|
||||||
((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t)))
|
|
||||||
(let* ((info (list :input-file temp-file))
|
|
||||||
(output "<html><body><p>hello</p></body></html>")
|
|
||||||
(result (z/org-html-add-body-classes output 'html info)))
|
|
||||||
(should (string-match-p "wip" result))
|
|
||||||
(should (string-match-p "no-sidenotes" result))))))
|
|
||||||
|
|
||||||
(ert-deftest test/add-body-classes-unchanged-when-no-classes ()
|
(ert-deftest test/add-body-classes-unchanged-when-no-classes ()
|
||||||
"Output should be unmodified when no special classes apply."
|
"Output should be unmodified when no special classes apply."
|
||||||
(with-temp-org-file "#+TITLE: Plain\n\nContent.\n"
|
(with-temp-org-file "#+TITLE: Plain\n\nContent.\n"
|
||||||
(cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) nil))
|
(cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil)))
|
||||||
((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil)))
|
|
||||||
(let* ((info (list :input-file temp-file))
|
(let* ((info (list :input-file temp-file))
|
||||||
(output "<html><body><p>hello</p></body></html>")
|
(output "<html><body><p>hello</p></body></html>")
|
||||||
(result (z/org-html-add-body-classes output 'html info)))
|
(result (z/org-html-add-body-classes output 'html info)))
|
||||||
(should (string= output result))))))
|
(should (string= output result))))))
|
||||||
|
|
||||||
(ert-deftest test/add-body-classes-ignores-non-html-backend ()
|
(ert-deftest test/add-body-classes-ignores-non-html-backend ()
|
||||||
"For non-HTML backends the function currently returns nil (known bug:
|
"For non-HTML backends the output should pass through unchanged."
|
||||||
`when' has no else branch so output is not passed through).
|
|
||||||
Fix: change `when' to `if' with output as the else clause.
|
|
||||||
This test documents current behaviour; flip the should once fixed."
|
|
||||||
(with-temp-org-file "#+TITLE: LaTeX\n\nContent.\n"
|
(with-temp-org-file "#+TITLE: LaTeX\n\nContent.\n"
|
||||||
(cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t))
|
(cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t)))
|
||||||
((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t)))
|
|
||||||
(let* ((info (list :input-file temp-file))
|
(let* ((info (list :input-file temp-file))
|
||||||
(output "\\documentclass{article}")
|
(output "\\documentclass{article}")
|
||||||
(result (z/org-html-add-body-classes output 'latex info)))
|
(result (z/org-html-add-body-classes output 'latex info)))
|
||||||
;; BUG: should be (should (string= output result))
|
(should (string= output result))))))
|
||||||
(should (null result))))))
|
|
||||||
|
|
||||||
;;; build-site-tests.el ends here
|
;;; build-site-tests.el ends here
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#+TITLE: Aphy Console
|
|
||||||
#+OPTIONS: num:nil title:nil toc:nil
|
|
||||||
#+NO_SIDENOTES: t
|
|
||||||
#+HIDDEN_PAGE: t
|
|
||||||
#+DATE: <2026-05-10 Sun>
|
|
||||||
|
|
||||||
#+BEGIN_EXPORT html
|
|
||||||
<main class="play-root">
|
|
||||||
<section class="play-hero">
|
|
||||||
<div>
|
|
||||||
<p class="play-kicker">Layer 3</p>
|
|
||||||
<h1>Aphy Console</h1>
|
|
||||||
<p class="play-lede">A friendly bot friend embedded in the handmade parts.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="play-console hidden-terminal">
|
|
||||||
<div class="play-console__screen">
|
|
||||||
<p>APHY BOOT: hello. I am not the soul of the website. I am more like a porch light with opinions.</p>
|
|
||||||
<p>LOG: Lima grounds the system. Sensei Chi slows it down. Young Z asks why. Future Z answers later.</p>
|
|
||||||
<p>ANOMALY: I keep finding kindness in places where code expected null.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
#+END_EXPORT
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
#+BEGIN_EXPORT html
|
#+BEGIN_EXPORT html
|
||||||
<main class="play-root play-page" data-play-page="bookshelf">
|
<main class="play-root play-page" data-play-page="bookshelf">
|
||||||
<a class="play-back" href="/play.html">Back to Play</a>
|
<a class="play-back" href="/play/play.html">Back to Play</a>
|
||||||
<header class="play-page-head">
|
<header class="play-page-head">
|
||||||
<p class="play-kicker">04 / Puzzle</p>
|
<p class="play-kicker">04 / Puzzle</p>
|
||||||
<h1>Bookshelf Sort</h1>
|
<h1>Bookshelf Sort</h1>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user