feat: Doing more polishing (M8b)

This commit is contained in:
gauvainboiche
2026-10-25 18:50:17 +01:00
parent c2103c32e7
commit c55e5a2484
34 changed files with 1605 additions and 106 deletions
+136
View File
@@ -0,0 +1,136 @@
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
let ctx = null;
let master = null;
let noiseBuf = null;
function context() {
if (!ctx) {
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch {
return null;
}
master = ctx.createGain();
master.connect(ctx.destination);
}
return ctx;
}
function unlock() {
const c = context();
if (c && c.state === "suspended") c.resume?.().catch?.(() => {});
}
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
// One note: a sine (or other) wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, at);
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(g).connect(master);
o.start(at);
o.stop(at + dur + 0.05);
}
// Filtered noise: paper, pages, thumps and clicks.
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
noiseBuf ??= (() => {
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
})();
const src = c.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = c.createBiquadFilter();
f.type = type;
f.Q.value = q;
f.frequency.setValueAtTime(freq, at);
if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(f).connect(g).connect(master);
src.start(at);
src.stop(at + dur + 0.05);
}
const SOUNDS = {
stampYes: (c, t) => {
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
},
stampNo: (c, t) => {
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
},
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
page: (c, t) => {
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
},
sign: (c, t) => {
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
},
telegram: (c, t) => {
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
},
alert: (c, t) => {
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
},
chime: (c, t) => {
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
},
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
click: (c, t) => {
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
},
fall: (c, t) => {
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
},
fanfare: (c, t) => {
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
},
};
function play(name) {
if (!enabled()) return;
const make = SOUNDS[name];
const c = context();
if (!make || !c) return;
if (c.state === "suspended") {
c.resume?.().catch?.(() => {});
return; // still locked: nothing to hear yet
}
master.gain.value = Math.max(0, Math.min(1, volume()));
try {
make(c, c.currentTime + 0.005);
} catch {
/* a failing sound never stops the game */
}
}
return { play, unlock, names: Object.keys(SOUNDS) };
}
+23 -8
View File
@@ -1,17 +1,22 @@
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
// controls and the way into the menu.
// Desk calendar: a dial clock that turns once per in-game day, the date, term progress, next
// decision dates, the brass speed controls and the way into the menu.
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $ } from "./desk.js";
import { $, dialSvg, setDial } from "./desk.js";
export function createCalendar(root, bal, { onSpeed, onMenu }) {
const speeds = bal.time.speeds;
root.innerHTML = `
<div class="cal__head"><span class="cal__weekday"></span></div>
<div class="cal__body">
<div class="cal__day"></div>
<div class="cal__month"></div>
<div class="cal__top">
${dialSvg("cal__dial")}
<div class="cal__date">
<div class="cal__day"></div>
<div class="cal__month"></div>
</div>
</div>
<div class="cal__count"></div>
<div class="cal__term"><span></span></div>
<dl class="cal__next">
@@ -38,17 +43,27 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
};
const dial = $(root, ".cal__dial");
let lastDay = -1;
function update(state) {
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".cal__weekday").textContent = p.weekday;
$(root, ".cal__day").textContent = p.day;
const dayEl = $(root, ".cal__day");
dayEl.textContent = p.day;
if (lastDay >= 0 && state.day !== lastDay && state.speed > 0 && state.speed <= 2) {
dayEl.classList.remove("is-flip");
void dayEl.offsetWidth; // restart the animation
dayEl.classList.add("is-flip");
}
lastDay = state.day;
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
const shown = Math.min(state.day + 1, bal.time.termDays);
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
const over = Boolean(state.over);
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
for (const b of root.querySelectorAll("button[data-speed]")) {
@@ -56,5 +71,5 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
}
}
return { update };
return { update, clockHand: (progress) => setDial(dial, progress) };
}
+9 -1
View File
@@ -63,6 +63,7 @@ export function createDashboard(root, scenario) {
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
}]));
const sparkLen = {};
const shownValue = {};
function trend(series, eps, goodUp) {
if (!series || series.length < 2) return ["", ""];
@@ -82,7 +83,14 @@ export function createDashboard(root, scenario) {
g.fill.style.width = `${v}%`;
g.el.classList.toggle("is-low", v < 30);
g.el.classList.toggle("is-high", v >= 70);
g.value.textContent = Math.round(v);
const r = Math.round(v);
g.value.textContent = r;
if (shownValue[id] !== undefined && shownValue[id] !== r) {
g.value.classList.remove("is-up", "is-down");
void g.value.offsetWidth; // restart the animation
g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down");
}
shownValue[id] = r;
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
const series = state.history.series[id] ?? [];
+48 -13
View File
@@ -1,26 +1,34 @@
// Centre folder: the weekly decision card as a paper document with two rubber
// stamps. Also shows the idle and end-of-term messages.
// Centre folder: the in-tray. The oldest waiting proposal is the document on top, answered with
// two rubber stamps; the others show as a pile of sheets under it. Also shows the idle and
// end-of-term messages.
import { t, tx, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, esc } from "./desk.js";
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
const STAMP_MS = 600; // how long the stamp animation plays before the paper leaves the tray
const LEAVE_MS = 320; // and how long the paper takes to slide away
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
export function createDecision(root, { cardsById, characters, inboxMax = 10, onAnswer, onStamp = () => {}, onProfile, keysEnabled = () => true }) {
root.innerHTML = `
<h2 class="inbox__tab">${t("inbox.title")}</h2>
<h2 class="inbox__tab">${t("inbox.title")} <span class="inbox__count" data-count></span></h2>
<p class="inbox__full" role="status" hidden>${t("inbox.full")}</p>
<div class="inbox__body"></div>`;
const body = $(root, ".inbox__body");
const countEl = $(root, "[data-count]");
const fullEl = $(root, ".inbox__full");
let shown = "";
let locked = false;
let latest = null;
let lastCount = 0;
let timers = [];
function choose(choice) {
if (locked) return;
const card = $(body, ".card");
if (!card) return;
const id = latest?.inbox[0]?.id;
if (!card || !id) return;
locked = true;
card.dataset.stamped = choice;
for (const b of card.querySelectorAll("button")) b.disabled = true;
@@ -28,7 +36,9 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
stamp.className = `card__stamp card__stamp--${choice}`;
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
card.append(stamp);
setTimeout(() => onAnswer(choice), STAMP_MS);
onStamp(choice);
timers.push(setTimeout(() => card.classList.add("card--leave"), STAMP_MS));
timers.push(setTimeout(() => onAnswer(choice, id), STAMP_MS + LEAVE_MS));
}
body.addEventListener("click", (e) => {
@@ -40,7 +50,7 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
const onKey = (e) => {
if (!keysEnabled() || !latest?.pendingCard) return;
if (!keysEnabled() || !latest?.inbox.length) return;
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target.closest?.("input, textarea, select")) return;
@@ -61,10 +71,10 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
function cardHtml(card, state) {
const from = characters[card.sender];
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
const date = dateParts(dateOfDay(state.startDate, state.inbox[0].day).iso);
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
return `
<article class="card card--${esc(card.doc)}">
<article class="card card--enter card--${esc(card.doc)}" data-pile="${Math.min(3, state.inbox.length - 1)}">
<header class="card__head">
<span class="card__kind">${t("kind." + card.doc)}</span>
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
@@ -80,15 +90,34 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
</article>`;
}
// The tab counts the proposals waiting; the pile under the top paper grows with them.
function updateTray(state) {
const n = state.inbox.length;
countEl.textContent = n ? `${n}/${inboxMax}` : "";
countEl.classList.toggle("is-warn", n >= inboxMax - 2);
if (n > lastCount) {
countEl.classList.remove("is-bump");
void countEl.offsetWidth; // restart the animation
countEl.classList.add("is-bump");
}
lastCount = n;
fullEl.hidden = !(state.pending.inboxFull && !state.over);
const card = $(body, ".card");
if (card) card.dataset.pile = Math.min(3, Math.max(0, n - 1));
}
function update(state) {
latest = state;
updateTray(state);
let kind = "idle";
if (state.over) kind = "over";
else if (state.pending.profile) kind = "profile";
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
else if (state.inbox.length) kind = `card:${state.inbox[0].id}`;
if (kind === shown) return;
shown = kind;
locked = false;
timers.forEach(clearTimeout);
timers = [];
body.classList.toggle("is-card", kind.startsWith("card:"));
if (kind === "profile") {
@@ -105,9 +134,15 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
} else if (kind === "over") {
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
} else {
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
body.innerHTML = cardHtml(cardsById.get(state.inbox[0].id), state);
}
}
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
return {
update,
destroy: () => {
timers.forEach(clearTimeout);
document.removeEventListener("keydown", onKey);
},
};
}
+19
View File
@@ -16,3 +16,22 @@ export const $ = (root, sel) => root.querySelector(sel);
// Escape text for use inside innerHTML templates.
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
// A 12-hour dial whose single hand makes one full turn per in-game day. progress is 0..1.
export function dialSvg(className = "") {
const ticks = Array.from({ length: 12 }, (_, i) => {
const long = i % 3 === 0;
return `<line x1="50" y1="${long ? 8 : 10}" x2="50" y2="${long ? 18 : 15}" transform="rotate(${i * 30} 50 50)"/>`;
}).join("");
return `
<svg class="dial ${className}" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<circle class="dial__face" cx="50" cy="50" r="46"/>
<g class="dial__ticks">${ticks}</g>
<line class="dial__hand" x1="50" y1="54" x2="50" y2="16" transform="rotate(0 50 50)"/>
<circle class="dial__pin" cx="50" cy="50" r="4"/>
</svg>`;
}
export function setDial(svg, progress) {
svg?.querySelector(".dial__hand")?.setAttribute("transform", `rotate(${(progress * 360).toFixed(1)} 50 50)`);
}
+66
View File
@@ -0,0 +1,66 @@
// A tiny Markdown renderer for the rules. It supports exactly what the rules use: #, ##, ### headings,
// paragraphs, - and 1. lists (no nesting), **bold**, *italic*, `code`, pipe tables, --- and > quotes.
// Everything is escaped first, so a rules file can never inject markup.
import { esc } from "./desk.js";
function inline(text) {
return esc(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?![*\w])/g, "$1<em>$2</em>");
}
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
export function renderMarkdown(src) {
const lines = src.replace(/\r\n?/g, "\n").split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
const h = /^(#{1,3})\s+(.*)$/.exec(line);
if (h) {
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
i++;
continue;
}
if (/^-{3,}\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
if (/^\|.*\|\s*$/.test(line) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
out.push(
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
`<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`,
);
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ""));
out.push(`<blockquote>${quote.map(inline).join(" ")}</blockquote>`);
continue;
}
const list = /^(-|\d+\.)\s+/.exec(line);
if (list) {
const tag = list[1] === "-" ? "ul" : "ol";
const re = tag === "ul" ? /^-\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const items = [];
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])[1]);
out.push(`<${tag}>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|-{3,}\s*$|>|\||(-|\d+\.)\s)/.test(lines[i])) para.push(lines[i++]);
out.push(`<p>${inline(para.join(" "))}</p>`);
}
return out.join("\n");
}
+31 -1
View File
@@ -7,6 +7,7 @@ import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
import { renderMarkdown } from "./markdown.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
@@ -17,6 +18,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
let armed = null; // "action:arg" waiting for a second click
let armTimer = 0;
let exportText = "";
const rulesCache = {}; // language -> rendered html, or "error"
const say = (text, kind = "ok") => { message = { text, kind }; };
const go = (next) => { screen = next; message = null; disarm(); render(); };
@@ -85,6 +87,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("load", t("menu.load"))}
${button("export", t("menu.export"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("quit", t("menu.quit"))}
</div>`;
@@ -100,6 +103,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("new", t("menu.new"))}
${button("load", t("menu.load"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("credits", t("menu.credits"))}
</div>`;
@@ -213,6 +217,13 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
</label>
<label class="menu__field menu__check">
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
</label>
<label class="menu__field">${t("settings.volume")}
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
</label>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
@@ -226,7 +237,23 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
// The rules are plain Markdown files (data/rules/rules.<lang>.md), fetched on first use.
function rules() {
const lang = getLang();
const body = rulesCache[lang];
if (body === undefined) {
rulesCache[lang] = null; // loading
fetch(`data/rules/rules.${lang}.md`)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
.catch(() => { rulesCache[lang] = "error"; })
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
}
const html = body === "error" ? `<p class="menu__msg menu__msg--error">${t("rules.error")}</p>` : body ?? `<p class="menu__sub">${t("rules.loading")}</p>`;
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
function render() {
root.innerHTML = `
@@ -296,6 +323,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
export: () => { exportText = encodeSave(getState()); go("export"); },
import: () => go("import"),
settings: () => go("settings"),
rules: () => go("rules"),
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
@@ -336,6 +364,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
root.addEventListener("change", async (e) => {
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
+3
View File
@@ -77,6 +77,9 @@ export function createNewspaper(root, tpl) {
if (key === shown) return;
shown = key;
root.classList.remove("is-fresh");
void root.offsetWidth; // restart the fade
root.classList.add("is-fresh");
const fill = (s) => s.replaceAll("{country}", state.countryName);
const [lead, ...rest] = computeHeadlines(state, tpl);
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
+56
View File
@@ -0,0 +1,56 @@
// Phone layout only: the shelf along the top of the desk. It shows the date and a small dial,
// cycles the speed, and picks which single paper is on the desk (tray, briefing, news,
// calendar, and the ledger while a budget review is open).
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, dialSvg, setDial } from "./desk.js";
const VIEWS = ["tray", "brief", "news", "clock"];
export function createShelf(root, bal, { getView, onView, onSpeed, onMenu }) {
const speeds = [0, ...bal.time.speeds];
root.innerHTML = `
<div class="shelf__time">
${dialSvg("shelf__dial")}
<div class="shelf__date"><b></b><span></span></div>
<button class="brass shelf__speed" data-speed-cycle></button>
</div>
<div class="shelf__tabs" role="group" aria-label="${t("shelf.nav")}">
${VIEWS.map((v) => `<button class="shelf__tab" data-view="${v}"><span>${t("shelf." + v)}</span>${v === "tray" ? `<i class="shelf__badge" data-badge></i>` : ""}</button>`).join("")}
<button class="shelf__tab shelf__tab--ledger" data-view="ledger" hidden><span>${t("shelf.ledger")}</span></button>
<button class="shelf__tab shelf__tab--menu" data-menu><span>${t("shelf.menu")}</span></button>
</div>`;
const dial = $(root, ".shelf__dial");
let speed = 0;
root.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.menu !== undefined) onMenu();
else if (b.dataset.speedCycle !== undefined) onSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length]);
else if (b.dataset.view) onView(b.dataset.view);
});
function update(state) {
const d = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".shelf__date b").textContent = `${d.weekday} ${d.day} ${d.month}`;
$(root, ".shelf__date span").textContent = d.year;
speed = state.speed;
const sp = $(root, ".shelf__speed");
sp.textContent = speed === 0 ? "II" : `${speed}×`;
sp.setAttribute("aria-label", speed === 0 ? t("cal.pause") : t("cal.speed", { n: speed }));
const view = getView();
const ledger = Boolean(state.pending.review && !state.pending.profile && !state.over);
$(root, '[data-view="ledger"]').hidden = !ledger;
for (const b of root.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === view));
const badge = $(root, "[data-badge]");
badge.textContent = state.inbox.length || "";
badge.hidden = state.inbox.length === 0;
badge.classList.toggle("is-warn", state.inbox.length >= bal.cards.inboxMax - 2);
}
return { update, clockHand: (p) => setDial(dial, p) };
}