Refactor org web content and simplify shared UI
All checks were successful
Build Org Website / build (push) Successful in 44s

This commit is contained in:
gitea-actions
2026-07-29 15:17:17 +01:00
parent db90e9f452
commit 7afc554f5d
31 changed files with 1156 additions and 2198 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -1,7 +0,0 @@
(function () {
"use strict";
var script = document.createElement("script");
script.src = "/assets/scripts/pages/ash-below-lake.js";
script.defer = true;
document.head.appendChild(script);
}());

View File

@@ -0,0 +1,114 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.ArchiveWorldState = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const VERSION = 1;
const STORAGE_KEY = "zxh_archive_world_v1";
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
const LANDMARK_IDS = Object.freeze([
"gate",
"library",
"study",
"inn",
"workshop",
"playroom",
"museum",
"garden"
]);
const SPAWN = Object.freeze({ x: 640, y: 830 });
function validateName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function validatePalette(value) {
return PALETTES.includes(value) ? value : null;
}
function fresh(name, palette) {
return {
version: VERSION,
name: validateName(name) || "",
palette: validatePalette(palette) || "brass",
discovered: [],
complete: false,
position: { x: SPAWN.x, y: SPAWN.y },
soundEnabled: false
};
}
function clamp(value, min, max, fallback) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validateName(candidate.name);
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const discovered = Array.isArray(candidate.discovered)
? Array.from(new Set(candidate.discovered.filter((id) => LANDMARK_IDS.includes(id))))
: [];
return {
version: VERSION,
name,
palette,
discovered,
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
position: {
x: clamp(candidate.position && candidate.position.x, 36, 1244, SPAWN.x),
y: clamp(candidate.position && candidate.position.y, 36, 924, SPAWN.y)
},
soundEnabled: candidate.soundEnabled === true
};
}
function parse(raw) {
if (!raw) return null;
try {
return normalize(JSON.parse(raw));
} catch (_error) {
return null;
}
}
function discover(state, landmarkId) {
const current = normalize(state);
if (!current || !LANDMARK_IDS.includes(landmarkId)) return current;
if (!current.discovered.includes(landmarkId)) current.discovered.push(landmarkId);
current.complete = LANDMARK_IDS.every((id) => current.discovered.includes(id));
return current;
}
function withPosition(state, x, y) {
const current = normalize(state);
if (!current) return null;
current.position.x = clamp(x, 36, 1244, SPAWN.x);
current.position.y = clamp(y, 36, 924, SPAWN.y);
return current;
}
return Object.freeze({
VERSION,
STORAGE_KEY,
PALETTES,
LANDMARK_IDS,
SPAWN,
validateName,
validatePalette,
fresh,
normalize,
parse,
discover,
withPosition
});
}));

View File

@@ -0,0 +1,420 @@
(function () {
"use strict";
const State = window.ArchiveWorldState;
const LANDMARKS = Object.freeze({
gate: landmark("Town Gate", "Arrivals", 640, 830,
"The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]),
library: landmark("Grand Library", "Knowledge", 260, 285,
"Long-lived explanations, categories, and learning notes fill the blue-roofed library.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]]),
study: landmark("Guild Study", "Work", 640, 250,
"Engineering notes, professional lessons, and active competencies cover every desk.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]]),
inn: landmark("Kitchen Inn", "Daily life", 1005, 270,
"Weekly reviews and ordinary days stay warm beside the inn's oven.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]]),
workshop: landmark("Workshop", "Living systems", 1030, 535,
"Trackers, services, plans, and practical machinery keep the town moving.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]]),
playroom: landmark("Playroom", "Experiments", 1005, 765,
"Games, generators, and stranger little mechanisms glow behind the bright windows.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]]),
museum: landmark("Lima Museum", "Keepsakes", 275, 765,
"Personal fragments, older writing, and small memories rest beneath the glass roof.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]]),
garden: landmark("Notes Garden", "Connections", 220, 515,
"Loose notes, paths between topics, and recently tended pages grow beyond the trellis.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]])
});
const PALETTE_TINTS = Object.freeze({
brass: 0xf1c46f,
moss: 0x91c788,
berry: 0xd894ad
});
const controller = {
state: null,
scene: null,
lock: false,
move: { up: false, down: false, left: false, right: false },
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
saveTimer: 0
};
function landmark(title, eyebrow, x, y, description, links) {
return Object.freeze({ title, eyebrow, x, y, description, links: Object.freeze(links) });
}
document.addEventListener("DOMContentLoaded", init);
function init() {
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
if (!root || !State) return;
const stored = State.parse(localStorage.getItem(State.STORAGE_KEY));
controller.state = stored;
bindInterface(root);
if (!window.Phaser) {
setStatus(root, "The world engine could not load. Every destination remains available in the plain directory.");
return;
}
if (stored) {
startGame(root);
} else {
openSetup(root);
}
}
function bindInterface(root) {
const setup = root.querySelector("[data-world-setup]");
const setupForm = root.querySelector("[data-world-setup-form]");
const preview = root.querySelector("[data-world-preview]");
const journal = root.querySelector("[data-world-journal]");
setup.addEventListener("cancel", (event) => event.preventDefault());
setupForm.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(setupForm);
const name = State.validateName(data.get("name"));
const palette = State.validatePalette(data.get("palette"));
const error = root.querySelector("[data-world-setup-error]");
if (!name || !palette) {
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
return;
}
controller.state = State.fresh(name, palette);
persist();
setup.close();
startGame(root);
});
root.querySelector("[data-world-preview-close]").addEventListener("click", () => preview.close());
preview.addEventListener("close", () => {
controller.lock = false;
root.querySelector("#archive-world-game").focus();
});
root.querySelector("[data-world-journal-open]").addEventListener("click", () => {
renderJournal(root);
controller.lock = true;
journal.showModal();
});
root.querySelector("[data-world-journal-close]").addEventListener("click", () => journal.close());
journal.addEventListener("close", () => {
controller.lock = false;
root.querySelector("[data-world-journal-open]").focus();
});
root.querySelector("[data-world-sound]").addEventListener("click", () => toggleSound(root));
root.querySelector("[data-world-reset]").addEventListener("click", () => {
if (!window.confirm("Reset your traveler, discoveries, and saved position?")) return;
localStorage.removeItem(State.STORAGE_KEY);
window.location.reload();
});
root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
};
button.addEventListener("pointerdown", down);
button.addEventListener("pointerup", up);
button.addEventListener("pointercancel", up);
button.addEventListener("pointerleave", up);
});
root.querySelector("[data-world-interact]").addEventListener("click", () => {
if (controller.scene) controller.scene.interact();
});
window.addEventListener("pagehide", savePosition);
document.addEventListener("visibilitychange", () => {
if (document.hidden) savePosition();
});
}
function openSetup(root) {
const dialog = root.querySelector("[data-world-setup]");
window.setTimeout(() => {
dialog.showModal();
dialog.querySelector("input[name=name]").focus();
}, 0);
}
function startGame(root) {
renderHud(root);
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: "archive-world-game",
width: 960,
height: 640,
backgroundColor: "#17251e",
pixelArt: true,
roundPixels: true,
physics: {
default: "arcade",
arcade: { debug: false }
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: ArchiveTownScene,
input: {
keyboard: true,
mouse: true,
touch: true
}
});
root.dataset.gameReady = "true";
root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = game;
}
class ArchiveTownScene extends Phaser.Scene {
constructor() {
super("ArchiveTown");
this.nearest = null;
this.lastStepAt = 0;
this.wasMoving = false;
}
preload() {
this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp");
this.load.image("traveler", "/assets/images/play/archive-world/traveler.png");
this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg");
this.load.audio("step", "/assets/audio/archive-world/step.wav");
this.load.audio("discover", "/assets/audio/archive-world/discover.wav");
this.load.audio("portal", "/assets/audio/archive-world/portal.wav");
}
create() {
controller.scene = this;
this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0);
this.physics.world.setBounds(0, 0, 1280, 960);
this.markers = {};
Object.entries(LANDMARKS).forEach(([id, item]) => {
const marker = this.add.circle(item.x, item.y, 22, 0xf4d98a, 0.16)
.setStrokeStyle(3, 0xf8e6a7, 0.9)
.setDepth(2);
marker.setData("landmarkId", id);
this.markers[id] = marker;
if (!controller.reducedMotion) {
this.tweens.add({
targets: marker,
scale: 1.18,
alpha: 0.58,
duration: 900,
yoyo: true,
repeat: -1,
delay: Object.keys(LANDMARKS).indexOf(id) * 90
});
}
});
this.player = this.physics.add.sprite(
controller.state.position.x,
controller.state.position.y,
"traveler"
).setDepth(4).setTint(PALETTE_TINTS[controller.state.palette]);
this.player.setCollideWorldBounds(true);
this.player.body.setSize(28, 34).setOffset(8, 58);
this.cameras.main.setBounds(0, 0, 1280, 960);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion ? 1 : 0.12, controller.reducedMotion ? 1 : 0.12);
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D,
interact: Phaser.Input.Keyboard.KeyCodes.E,
enter: Phaser.Input.Keyboard.KeyCodes.ENTER
});
this.ambient = this.sound.add("ambient", { loop: true, volume: 0.24 });
this.stepSound = this.sound.add("step", { volume: 0.22 });
this.discoverSound = this.sound.add("discover", { volume: 0.35 });
this.portalSound = this.sound.add("portal", { volume: 0.3 });
if (controller.state.soundEnabled) this.enableSound();
setStatus(document.querySelector(".archive-world"), `Welcome, ${controller.state.name}. Walk near a glowing landmark and press E, Space, or Enter.`);
this.updateNearest();
}
update(time) {
if (!this.player) return;
let dx = 0;
let dy = 0;
if (!controller.lock) {
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
}
const moving = dx !== 0 || dy !== 0;
if (moving) {
const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175);
this.player.setVelocity(vector.x, vector.y);
if (dx) this.player.setFlipX(dx < 0);
if (!controller.reducedMotion) {
this.player.rotation = Math.sin(time / 80) * 0.025;
this.player.setScale(1, 0.98 + Math.abs(Math.sin(time / 95)) * 0.04);
}
if (controller.state.soundEnabled && time - this.lastStepAt > 340) {
this.stepSound.play();
this.lastStepAt = time;
}
if (time - controller.saveTimer > 700) {
controller.saveTimer = time;
savePosition();
}
} else {
this.player.setVelocity(0, 0);
this.player.rotation = 0;
this.player.setScale(1);
}
this.wasMoving = moving;
this.updateNearest();
if (!controller.lock && (
Phaser.Input.Keyboard.JustDown(this.keys.interact) ||
Phaser.Input.Keyboard.JustDown(this.keys.enter) ||
Phaser.Input.Keyboard.JustDown(this.cursors.space)
)) this.interact();
}
updateNearest() {
let nearest = null;
let distance = Infinity;
Object.entries(LANDMARKS).forEach(([id, item]) => {
const nextDistance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y);
if (nextDistance < distance) {
nearest = id;
distance = nextDistance;
}
this.markers[id].setStrokeStyle(nextDistance < 95 ? 5 : 3, nextDistance < 95 ? 0xffffff : 0xf8e6a7, 0.95);
});
const next = distance < 95 ? nearest : null;
if (next !== this.nearest) {
this.nearest = next;
const root = document.querySelector(".archive-world");
setStatus(root, next
? `${LANDMARKS[next].title} is nearby. Press E, Space, Enter, or Explore.`
: "Follow the stone paths toward a glowing landmark.");
}
}
interact() {
if (controller.lock) return;
if (!this.nearest) {
setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles.");
return;
}
openPreview(document.querySelector(".archive-world"), this.nearest);
}
enableSound() {
if (this.sound.locked && this.sound.unlock) this.sound.unlock();
this.sound.mute = false;
if (!this.ambient.isPlaying) this.ambient.play();
}
disableSound() {
this.sound.mute = true;
if (this.ambient.isPlaying) this.ambient.pause();
}
}
function openPreview(root, id) {
const item = LANDMARKS[id];
if (!item) return;
const wasDiscovered = controller.state.discovered.includes(id);
controller.state = State.discover(controller.state, id);
persist();
controller.lock = true;
root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow;
root.querySelector("[data-world-preview-title]").textContent = item.title;
root.querySelector("[data-world-preview-description]").textContent = item.description;
const links = root.querySelector("[data-world-preview-links]");
links.replaceChildren();
item.links.forEach(([label, href]) => {
const anchor = document.createElement("a");
anchor.href = href;
anchor.textContent = label;
anchor.addEventListener("click", () => {
if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play();
savePosition();
});
links.appendChild(anchor);
});
renderHud(root);
if (!wasDiscovered && controller.scene && controller.state.soundEnabled) controller.scene.discoverSound.play();
root.querySelector("[data-world-preview]").showModal();
setStatus(root, wasDiscovered
? `${item.title} is already recorded in your journal.`
: `${item.title} was added to your discovery journal.`);
}
function toggleSound(root) {
if (!controller.state || !controller.scene) return;
controller.state.soundEnabled = !controller.state.soundEnabled;
if (controller.state.soundEnabled) controller.scene.enableSound();
else controller.scene.disableSound();
persist();
renderHud(root);
setStatus(root, controller.state.soundEnabled ? "Ambient sound is on." : "Sound is muted.");
}
function savePosition() {
if (!controller.state || !controller.scene || !controller.scene.player) return;
controller.state = State.withPosition(controller.state, controller.scene.player.x, controller.scene.player.y);
persist();
}
function persist() {
if (controller.state) localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state));
}
function renderHud(root) {
if (!controller.state) return;
root.querySelector("[data-world-player]").textContent = controller.state.name;
root.querySelector("[data-world-discovery-count]").textContent = `${controller.state.discovered.length} / ${State.LANDMARK_IDS.length}`;
root.querySelector("[data-world-sound]").textContent = controller.state.soundEnabled ? "Sound: on" : "Sound: muted";
const badge = root.querySelector("[data-world-badge]");
badge.hidden = !controller.state.complete;
renderJournal(root);
}
function renderJournal(root) {
if (!controller.state) return;
const list = root.querySelector("[data-world-journal-list]");
list.replaceChildren();
State.LANDMARK_IDS.forEach((id) => {
const row = document.createElement("li");
const found = controller.state.discovered.includes(id);
row.className = found ? "is-discovered" : "";
row.textContent = `${found ? "Discovered" : "Unexplored"}${LANDMARKS[id].title}`;
list.appendChild(row);
});
}
function setStatus(root, message) {
const status = root && root.querySelector("[data-world-status]");
if (status) status.textContent = message;
}
}());

File diff suppressed because it is too large Load Diff

View File

@@ -236,5 +236,7 @@ async function loadBoard() {
populateMobileControls(items);
}
renderLevelTabs();
loadBoard();
if (document.getElementById("kanban-board")) {
renderLevelTabs();
loadBoard();
}

View File

@@ -76,7 +76,7 @@
caption: "A wooden moon rolls beneath the cabinet whenever nobody is looking.",
curated: [
["Play hub", "/play/play.html"],
["Ash Below the Lake", "/play/rpg.html"],
["The Archive World", "/play/rpg.html"],
["The Rain Index", "/play/the-rain-index.html"]
],
include: ["/play/"],

View File

@@ -14,7 +14,7 @@
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 };
const inits = { hub, memory, constellation, poem, bookshelf, ink, terminal, study, sigil };
if (inits[page]) inits[page](root);
});
@@ -256,63 +256,6 @@
}
}
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");
@@ -492,353 +435,6 @@
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) => {

View File

@@ -5,6 +5,8 @@
(function () {
"use strict";
if (!document.getElementById("wird-app")) return;
const API = "/api/wird";
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"

View File

@@ -1,11 +1,11 @@
/* 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; }
if (!toc) 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; }
if (!links.length) return;
// Map: id -> link
const linkById = new Map();
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById(id);
if (el) linkById.set(id, a);
});
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
if (!linkById.size) return;
// Headings to observe (h2h4 usually)
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]

File diff suppressed because one or more lines are too long

View File

@@ -307,16 +307,14 @@
transform: rotate(-4deg);
}
.bookshelf,
.timeline-list {
.bookshelf {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: stretch;
}
.book-spine,
.timeline-card {
.book-spine {
touch-action: none;
border: 1px solid var(--border);
border-radius: 7px;
@@ -350,54 +348,6 @@
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;
@@ -495,316 +445,358 @@
background: var(--surface);
}
.rpg-shell {
display: grid;
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
.archive-world {
--world-ink: #17251e;
--world-panel: #f3ead2;
--world-paper: #fff9e9;
--world-line: #8b6841;
--world-brass: #d49a3a;
--world-moss: #527657;
--world-berry: #9d5268;
max-width: 1440px;
}
.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;
.archive-world__heading {
max-width: 900px;
}
.ash-rpg-root .play-page-head {
padding: 1rem 0;
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
.archive-world__shell {
overflow: hidden;
border: 2px solid var(--world-line);
border-radius: 12px;
background: var(--world-ink);
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
}
.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);
.archive-world__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
padding: 0.75rem;
border-bottom: 2px solid #6e5738;
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);
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
#2d3f33;
color: #fff6dc;
}
.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 {
.archive-world__toolbar > div {
display: grid;
gap: 0.75rem;
min-width: 110px;
}
.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;
.archive-world__toolbar span {
color: #d8c9a6;
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.08em;
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);
.archive-world__toolbar button,
.archive-world__controls button,
.archive-world__dialog button {
border: 1px solid #d6b777;
border-radius: 6px;
background: #fff3d2;
color: #2b2116;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.ash-rpg-root button:disabled {
cursor: not-allowed;
opacity: 0.42;
.archive-world__toolbar button {
margin-left: auto;
padding: 0.55rem 0.75rem;
}
.rpg-mobile-pad {
grid-template-columns: repeat(3, minmax(0, 1fr));
.archive-world__toolbar button + button {
margin-left: 0;
}
.rpg-mobile-pad [data-rpg-move="up"],
.rpg-mobile-pad [data-rpg-move="down"] {
.archive-world__toolbar button:hover,
.archive-world__toolbar button:focus-visible,
.archive-world__controls button:hover,
.archive-world__controls button:focus-visible,
.archive-world__dialog button:hover,
.archive-world__dialog button:focus-visible {
border-color: #fff0b5;
background: #fffaf0;
box-shadow: 0 0 0 3px rgba(255, 236, 173, 0.24);
}
.archive-world__badge {
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid #f2d483;
border-radius: 999px;
background: #604b1f;
color: #fff1b6;
font-size: 0.78rem;
font-weight: 900;
}
.archive-world__game {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
max-height: min(72vh, 760px);
overflow: hidden;
outline: none;
background: #17251e;
image-rendering: pixelated;
}
.archive-world__game:focus-visible {
box-shadow: inset 0 0 0 4px #fff0a9;
}
.archive-world__game canvas {
display: block;
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
}
.archive-world__loading {
position: absolute;
inset: 45% 0 auto;
margin: 0;
color: #f5e4b7;
text-align: center;
font-weight: 800;
}
.archive-world[data-game-ready="true"] .archive-world__loading {
display: none;
}
.archive-world__controls {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
padding: 0.8rem;
border-top: 2px solid #6e5738;
background: #2d3f33;
touch-action: none;
}
.archive-world__dpad {
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 42px);
gap: 0.3rem;
}
.archive-world__dpad [data-world-move="up"] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="left"] {
.archive-world__dpad [data-world-move="left"] {
grid-column: 1;
}
.rpg-mobile-pad [data-rpg-act] {
.archive-world__dpad [data-world-move="down"] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="right"] {
.archive-world__dpad [data-world-move="right"] {
grid-column: 3;
}
.rpg-stats {
display: grid;
gap: 0.55rem;
margin: 0;
.archive-world__controls button {
min-height: 42px;
padding: 0.5rem 0.8rem;
user-select: none;
-webkit-user-select: none;
}
.rpg-stats div {
.archive-world__interact {
min-width: 110px;
min-height: 64px !important;
}
.archive-world__status,
.archive-world__instructions {
margin: 0;
padding: 0.65rem 1rem;
color: #f5e4b7;
text-align: center;
}
.archive-world__status {
border-top: 1px solid rgba(245, 228, 183, 0.16);
font-weight: 800;
}
.archive-world__instructions {
padding-top: 0;
color: #cbbd9d;
font-size: 0.85rem;
}
.archive-world__dialog {
width: min(92vw, 620px);
max-height: 86vh;
overflow: auto;
border: 2px solid var(--world-line);
border-radius: 10px;
padding: 1.2rem;
background:
linear-gradient(rgba(255, 255, 255, 0.45), transparent 50%),
var(--world-panel);
color: #2b2116;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.46);
}
.archive-world__dialog::backdrop {
background: rgba(15, 24, 19, 0.78);
backdrop-filter: blur(3px);
}
.archive-world__dialog h2 {
margin-top: 0.25rem;
font-family: Georgia, "Times New Roman", serif;
}
.archive-world__dialog form,
.archive-world__dialog label,
.archive-world__dialog fieldset {
display: grid;
gap: 0.65rem;
}
.archive-world__dialog input[type="text"] {
width: 100%;
border: 1px solid var(--world-line);
border-radius: 6px;
padding: 0.7rem;
background: var(--world-paper);
color: #2b2116;
font: inherit;
}
.archive-world__dialog fieldset {
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid #b79b70;
}
.archive-world__dialog fieldset label {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
gap: 0.4rem;
align-items: center;
}
.rpg-stats dt,
.rpg-list h2 {
margin: 0;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
.archive-world__dialog button {
padding: 0.65rem 0.85rem;
}
.rpg-stats dd {
.palette-swatch {
width: 18px;
height: 18px;
border: 2px solid #392e22;
border-radius: 3px;
}
.palette-swatch--brass { background: #f1c46f; }
.palette-swatch--moss { background: #91c788; }
.palette-swatch--berry { background: #d894ad; }
.archive-world__error {
min-height: 1.4em;
margin: 0;
color: #8b2635;
font-weight: 800;
}
.rpg-list ul {
.archive-world__preview-links {
display: grid;
gap: 0.45rem;
margin: 0.45rem 0 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin: 1rem 0;
}
.archive-world__preview-links a {
padding: 0.75rem;
border: 1px solid var(--world-line);
border-radius: 6px;
background: var(--world-paper);
color: #3a2b18;
font-weight: 800;
text-decoration: none;
}
.archive-world__preview-links a:hover,
.archive-world__preview-links a:focus-visible {
background: #fff;
box-shadow: 0 0 0 3px rgba(82, 118, 87, 0.25);
}
.archive-world__journal {
display: grid;
gap: 0.5rem;
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;
.archive-world__journal li {
padding: 0.65rem;
background: #05070c;
color: var(--ash-text);
font: inherit;
border: 1px dashed #aa977b;
border-radius: 5px;
color: #756956;
}
.ash-rpg-root.is-lake-awake .rpg-dialogue,
.ash-rpg-root.is-lake-awake .rpg-panel {
border-color: rgba(219, 79, 98, 0.36);
.archive-world__journal li.is-discovered {
border-style: solid;
border-color: #638568;
background: #e5efdc;
color: #29452e;
font-weight: 800;
}
.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);
.archive-world__directory {
margin-top: 1.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
.ash-rpg-root.is-ending .rpg-panel {
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
.archive-world__directory summary {
padding: 0.9rem 1rem;
font-weight: 900;
cursor: pointer;
}
.archive-world__directory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
padding: 0 1rem 1rem;
}
.archive-world__directory-grid section {
padding: 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
}
.archive-world__directory-grid h2 {
margin: 0 0 0.45rem;
font-size: 1rem;
}
.archive-world__noscript {
padding: 0.8rem;
border-left: 4px solid var(--world-brass);
background: var(--surface);
}
@media (max-width: 760px) {
.play-hero,
.play-console,
.recipe-tool,
.sigil-tool,
.rpg-shell {
.sigil-tool {
grid-template-columns: 1fr;
}
@@ -821,12 +813,48 @@
min-height: 160px;
}
.ash-choicebar,
.ash-name-form {
.archive-world__toolbar > div {
min-width: 82px;
}
.archive-world__toolbar button {
margin-left: 0;
}
.archive-world__game {
min-height: 360px;
max-height: 62vh;
}
.archive-world__dialog fieldset,
.archive-world__preview-links,
.archive-world__directory-grid {
grid-template-columns: 1fr;
}
.ash-rpg-canvas {
min-height: auto;
}
@media (max-width: 480px) {
.archive-world__game {
min-height: 300px;
}
.archive-world__controls {
justify-content: space-between;
}
.archive-world__dpad {
grid-template-columns: repeat(3, 42px);
}
}
@media (prefers-reduced-motion: reduce) {
.archive-world *,
.archive-world *::before,
.archive-world *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}