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
+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);
},
};
}