Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Heldt f9894abfc9 Add free-text day notes with an always-visible notes log
A new 📝 Note quick-action logs a free-text note (with a date, optional
photo, and any text), defaulting to the day you're viewing. Notes are a
distinct "note" event type, so they edit, sync and delete like any other
event, and a new Notes section lists them newest-first across every day —
independent of the day picker — so records like vaccinations stay visible
whatever day you're looking at.
2026-08-02 13:37:39 +00:00
Alexander Heldt 09d9d38c12 Add metric checkboxes to the daily counts chart
The Daily counts legend is now a Pees/Poos/Meals checkbox group, so you
can hide metrics and focus on just the ones you care about. The choice is
persisted per device, at least one metric is always kept visible, and the
y-axis and bar widths adapt to the selected metrics.
2026-08-02 13:26:38 +00:00
Alexander Heldt a44c75d2d4 Let the confetti fade at its peak instead of falling back
Drop the descent phase: each emoji now rises to its peak, slows (ease-out) and
fades out at the top rather than arcing back down. Removes the now-unused --endY.
2026-08-01 18:28:40 +00:00
Alexander Heldt e2f99590f1 Make the pee/poo confetti a bottom-up fireworks fountain
Rework the effect: instead of a small burst from the tapped button, 30 emoji
launch from across the bottom of the screen, rise to random peaks while
drifting sideways, then arc back down and fade — staggered launches and varied
speed/size/rotation for a fuller, firework feel. Still pointer-events:none,
self-removing, and gated by the Settings toggle and reduced-motion.
2026-08-01 18:24:33 +00:00
Alexander Heldt 52c50c97b1 Celebrate a logged pee/poo with emoji confetti
Tapping the pee or poo quick-log button now sets off a short burst of 💧/💩
particles from the button — pure DOM + CSS, pointer-events:none so it never
blocks taps, particles self-remove on animation end. It honours
prefers-reduced-motion, and there's a "Pee/poo confetti" switch in Settings
(device-local, on by default) to turn it off.
2026-08-01 18:18:26 +00:00
Alexander Heldt 1ed325b834 Keep the header and weight rows tidy when the age is long
The age counter can read "16 weeks (3 months and 3 weeks) old", which broke
two tight layouts: the header title never truncated, so a long name (or the
wide status pill) collided with the action buttons and the age wrapped to
three lines; and each weight-log row embedded the full age, wrapping to two
lines.

Add compact age formatters and use them where space is tight: formatAgeShort
("16 wk · 3 mo 3 wk") in the header, formatAgeWeeks ("16 wk") in weight rows;
the verbose form stays on the roomy weight-chart caption. Make the header
robust — the title flexes and the name ellipsis-truncates so the buttons are
never pushed, while the age wraps rather than truncating so it's never cut
off. Weight rows keep the date/age on one line with the value pinned right.
2026-08-01 18:11:03 +00:00
4 changed files with 269 additions and 23 deletions
+182 -18
View File
@@ -21,6 +21,7 @@
"poo": "Poo",
"weight": "Weigh-in",
"training": "Training",
"note": "Note",
};
// ---------- photos: IndexedDB store ----------
@@ -291,6 +292,24 @@
return `${wk} (${monthPhrase}) old`;
}
// Compact one-line age for space-tight spots (header): keeps the weeks + months
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
function formatAgeShort(birthday, at) {
const a = ageParts(birthday, at);
if (!a) return "";
const wk = `${a.weeks} wk`;
if (a.months < 1) return wk;
const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`;
if (a.months >= 4) return mo;
return `${wk} · ${mo}`;
}
// Weeks-only age for dense lists (weight rows): "16 wk".
function formatAgeWeeks(birthday, at) {
const a = ageParts(birthday, at);
return a ? `${a.weeks} wk` : "";
}
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
// 08 weeks 2022h, 816 weeks 1820h, then 1618h to 6 months and 1416h to
// 12 months. No birthday (or an adult dog) → no goal.
@@ -876,6 +895,53 @@
}
}
// Cross-day log of free-text notes (vaccinations, vet visits, milestones…),
// newest first. Unlike History this ignores the day picker so the record is
// always visible regardless of which day you're viewing.
function renderNotes(events) {
const notes = events
.filter(e => e.type === "note")
.sort((a, b) => b.at - a.at);
const list = document.getElementById("notes-list");
const empty = document.getElementById("notes-empty");
list.innerHTML = "";
if (notes.length === 0) { empty.hidden = false; return; }
empty.hidden = true;
const birthday = loadConfig().birthday;
for (const ev of notes) {
const li = document.createElement("li");
li.className = "event";
li.dataset.type = "note";
li.dataset.id = ev.id;
const dateStr = new Date(ev.at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const age = formatAgeWeeks(birthday, ev.at);
li.innerHTML = `
<span class="dot"></span>
<span class="note-date">${escapeText(age ? `${dateStr} · ${age}` : dateStr)}</span>
<span class="note-text"></span>
`;
li.querySelector(".note-text").textContent = ev.note || "";
li.addEventListener("click", () => openEditDialog(ev));
for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
img.className = "thumb";
img.alt = "photo";
img.loading = "lazy";
img.dataset.photoId = pid;
img.addEventListener("click", (e) => {
e.stopPropagation();
openLightbox(pid);
});
li.appendChild(img);
photoSrc(pid).then(url => { if (url) img.src = url; });
}
list.appendChild(li);
}
}
// ---------- lightbox ----------
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
@@ -909,6 +975,27 @@
render();
}
// Which metrics the daily-counts chart shows. Device-global, toggled via the
// checkboxes under the chart. At least one is always kept on so the chart is
// never empty; an invalid/empty stored value falls back to all three.
const COUNTS_METRICS_KEY = "puppy-tracker:counts-metrics:v1";
const COUNTS_METRIC_KEYS = ["pees", "poos", "meals"];
function countsMetrics() {
let stored;
try { stored = JSON.parse(localStorage.getItem(COUNTS_METRICS_KEY)); } catch { /* ignore */ }
const on = Array.isArray(stored)
? COUNTS_METRIC_KEYS.filter(k => stored.includes(k))
: [];
return on.length ? on : COUNTS_METRIC_KEYS.slice();
}
function setCountsMetrics(keys) {
// Never let the user hide everything — keep at least one metric visible.
const on = COUNTS_METRIC_KEYS.filter(k => keys.includes(k));
if (!on.length) { renderChartWindow(); return; } // restore the checkbox we just rejected
try { localStorage.setItem(COUNTS_METRICS_KEY, JSON.stringify(on)); } catch { /* ignore */ }
render();
}
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow() {
const n = chartDays();
@@ -920,6 +1007,10 @@
document.querySelectorAll(".chart-days-picker button").forEach(b => {
b.classList.toggle("active", Number(b.dataset.days) === n);
});
const on = countsMetrics();
document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => {
cb.checked = on.includes(cb.dataset.metric);
});
}
// ---------- daily charts ----------
@@ -1076,13 +1167,19 @@
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals]));
const series = [
{ key: "pees", label: "Pees", cls: "bar-pee" },
{ key: "poos", label: "Poos", cls: "bar-poo" },
{ key: "meals", label: "Meals", cls: "bar-eat" },
].filter(s => countsMetrics().includes(s.key));
const rawMax = Math.max(0, ...days.flatMap(d => series.map(s => d[s.key])));
const { yMax, steps: ySteps } = niceAxis(rawMax);
const groupGap = days.length > 14 ? 2 : 4;
const innerBarGap = days.length > 14 ? 0.5 : 1.5;
const groupW = (innerW - (days.length - 1) * groupGap) / days.length;
const barW = (groupW - 2 * innerBarGap) / 3;
const barW = (groupW - (series.length - 1) * innerBarGap) / series.length;
const parts = [];
for (let i = 0; i <= ySteps; i++) {
@@ -1092,12 +1189,6 @@
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${v}</text>`);
}
const series = [
{ key: "pees", label: "Pees", cls: "bar-pee" },
{ key: "poos", label: "Poos", cls: "bar-poo" },
{ key: "meals", label: "Meals", cls: "bar-eat" },
];
const selYmd = ymd(selectedDay());
days.forEach((d, i) => {
const isToday = i === days.length - 1;
@@ -1669,7 +1760,7 @@
li.className = "ww weight-ww";
const date = document.createElement("span");
date.className = "ww-range";
const age = formatAge(birthday, w.at);
const age = formatAgeWeeks(birthday, w.at);
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
const val = document.createElement("span");
@@ -1888,7 +1979,7 @@
const ageEl = document.getElementById("puppy-age");
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker";
document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker";
const ageText = formatAge(cfg.birthday);
const ageText = formatAgeShort(cfg.birthday);
ageEl.textContent = ageText;
ageEl.hidden = !ageText;
// Use the puppy's name in the sleep-timeline heading rather than assuming a
@@ -1930,6 +2021,7 @@
renderHourHeatmap(events);
renderTraining(events);
renderWeight(events);
renderNotes(events);
renderHistory(events);
}
@@ -2161,11 +2253,21 @@
function openNoteDialog(type) {
pendingType = type;
noteInput.value = "";
noteTimeEdited = false;
const now = Date.now();
noteDate.value = toDateInput(now);
noteTime.value = toTimeInput(now);
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
const isNote = type === "note";
// A note is about a day, so default it to the day you're viewing (at the
// current clock time). The logging types default to "now"; leaving
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
let base = Date.now();
if (isNote) {
const day = selectedDay();
const now = new Date();
day.setHours(now.getHours(), now.getMinutes(), 0, 0);
base = day.getTime();
}
noteTimeEdited = isNote;
noteDate.value = toDateInput(base);
noteTime.value = toTimeInput(base);
noteTitle.textContent = isNote ? "Add note" : `Log ${EVENT_LABELS[type]}`;
const isWeight = type === "weight";
const isEat = type === "eat";
noteWeightField.hidden = !isWeight;
@@ -2215,6 +2317,10 @@
document.getElementById("note-save").addEventListener("click", async (e) => {
e.preventDefault();
if (!pendingType) { noteDialog.close(); return; }
if (pendingType === "note" && noteInput.value.trim() === "" && notePhotos.length === 0) {
alert("Write something for the note, or add a photo.");
return;
}
let weight;
if (pendingType === "weight") {
weight = parseFloat(noteWeight.value);
@@ -2404,6 +2510,7 @@
const settingsBirthday = document.getElementById("settings-birthday");
const settingsPedigree = document.getElementById("settings-pedigree");
const settingsTheme = document.getElementById("settings-theme");
const settingsConfetti = document.getElementById("settings-confetti");
// Apply live so the toggle previews immediately (independent of Save/Cancel).
settingsTheme.addEventListener("change", () => {
@@ -2416,6 +2523,7 @@
settingsBirthday.value = cfg.birthday;
settingsPedigree.value = cfg.pedigreeId;
settingsTheme.checked = effectiveTheme() === "dark";
settingsConfetti.checked = confettiEnabled();
settingsDialog.showModal();
setTimeout(() => settingsName.focus(), 50);
}
@@ -2431,6 +2539,7 @@
updatedAt: Date.now(),
};
saveConfig(cfg); // cache locally for instant + offline paint
setConfettiEnabled(settingsConfetti.checked); // device-local, not synced
renderHeader();
refreshPedigreeButton();
settingsDialog.close();
@@ -3159,6 +3268,53 @@
function quickLog(type) {
const ev = addEvent(type, "", Date.now());
showSnackbar(`${EVENT_LABELS[type]} logged`, ev);
if (type === "pee" || type === "poo") pottyConfetti(type);
}
// A little burst of 💧/💩 from the tapped button when a pee/poo is logged.
// Pure DOM + CSS; particles remove themselves when their animation ends.
// Skipped when disabled in Settings or the user prefers reduced motion.
// Device-local preference (like the theme), on by default.
const CONFETTI_KEY = "puppy-tracker:confetti:v1";
function confettiEnabled() {
try { return localStorage.getItem(CONFETTI_KEY) !== "off"; } catch { return true; }
}
function setConfettiEnabled(on) {
try { localStorage.setItem(CONFETTI_KEY, on ? "on" : "off"); } catch { /* ignore */ }
}
let confettiLayerEl = null;
function confettiLayer() {
if (!confettiLayerEl) {
confettiLayerEl = document.createElement("div");
confettiLayerEl.id = "confetti-layer";
document.body.appendChild(confettiLayerEl);
}
return confettiLayerEl;
}
function pottyConfetti(type) {
if (!confettiEnabled()) return;
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const emoji = type === "poo" ? "💩" : "💧";
const layer = confettiLayer();
const W = window.innerWidth, H = window.innerHeight;
for (let i = 0; i < 30; i++) {
const piece = document.createElement("span");
piece.className = "confetti-piece";
piece.textContent = emoji;
// Launch from a point across the bottom, shoot up to a random peak while
// drifting sideways, slowing and fading out as it reaches the top.
const peakY = -Math.round(H * (0.45 + Math.random() * 0.45));
piece.style.left = `${Math.round(W * (0.1 + Math.random() * 0.8))}px`;
piece.style.top = `${H + 20}px`;
piece.style.setProperty("--peakY", `${peakY}px`);
piece.style.setProperty("--dx", `${Math.round((Math.random() * 2 - 1) * W * 0.22)}px`);
piece.style.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`);
piece.style.fontSize = `${Math.round(16 + Math.random() * 18)}px`;
piece.style.animationDuration = `${Math.round(1100 + Math.random() * 700)}ms`;
piece.style.animationDelay = `${Math.round(Math.random() * 280)}ms`;
piece.addEventListener("animationend", () => piece.remove());
layer.appendChild(piece);
}
}
snackbarUndo.addEventListener("click", () => {
@@ -3217,9 +3373,9 @@
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.type;
// Weigh-ins need a typed value and meals ask for grams, so those two
// keep the full dialog.
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
// Weigh-ins need a typed value, meals ask for grams, and a note is all
// free text — so these open the full dialog instead of one-tap logging.
if (type === "weight" || type === "eat" || type === "note") { openNoteDialog(type); return; }
quickLog(type);
});
});
@@ -3228,6 +3384,14 @@
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
});
document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => {
cb.addEventListener("change", () => {
const on = [...document.querySelectorAll("#counts-metrics input[data-metric]:checked")]
.map(el => el.dataset.metric);
setCountsMetrics(on);
});
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls
// past. rAF-throttled: scroll events fire far more often than we can paint.
{
+4
View File
@@ -1,4 +1,8 @@
[
{ "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" },
{ "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" },
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off 💧/💩 fireworks that shoot up from the bottom of the screen — a little celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
{ "date": "2026-08-01", "text": "Tidied the header on long names and ages — the name now truncates instead of shoving the buttons, and the age reads as a compact \"16 wk · 3 mo 3 wk\"; weight-log rows are a single line again (\"Aug 1 · 16 wk\")" },
{ "date": "2026-07-26", "text": "Added a fan-chart view of the pedigree (toggle it in the header): your dog at the centre with each generation fanning outward as a ring, so many generations fit at once without the tree sprawling sideways — tap a wedge for that dog, and repeated ancestors keep their colour" },
{ "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" },
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/ buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
+15 -4
View File
@@ -113,6 +113,7 @@
<button class="action pee" data-type="pee">💧 Pee</button>
<button class="action poo" data-type="poo">💩 Poo</button>
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
<button class="action note" data-type="note">📝 Note</button>
</div>
</section>
@@ -203,10 +204,10 @@
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend">
<span class="lg pee"><span class="sw"></span>Pees</span>
<span class="lg poo"><span class="sw"></span>Poos</span>
<span class="lg eat"><span class="sw"></span>Meals</span>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
@@ -261,6 +262,12 @@
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</section>
<section class="notes-log" data-panel="notes">
<h2>Notes</h2>
<ul id="notes-list" class="event-list"></ul>
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
</section>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
@@ -335,6 +342,10 @@
<span>Dark mode</span>
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
</label>
<label class="toggle-row">
<span>Pee/poo confetti 💩</span>
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
</label>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
+68 -1
View File
@@ -11,6 +11,7 @@
--poo: #8a5a3b;
--weight: #2bb3a3;
--training: #b04ecf;
--note: #6f7a90;
--danger: #d64545;
--gain: #2e9e5b;
--border: #e9e6f5;
@@ -78,13 +79,24 @@ h1 {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
}
/* Truncate the name so a long one never runs into the action buttons. Scoped to
the header title so the auth-screen heading is unaffected. */
#app-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.puppy-age {
font-size: 0.8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
/* One line in the common case; on a very narrow screen it wraps rather than
truncating, so the age is never cut off. The name (#app-title) is what
truncates to keep the buttons clear. */
}
.header-actions {
@@ -255,6 +267,7 @@ button.action.eat { background: var(--eat); }
button.action.pee { background: var(--pee); color: #2b240a; }
button.action.poo { background: var(--poo); }
button.action.weight { background: var(--weight); }
button.action.note { background: var(--note); }
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
but fully tappable, so corrections are never blocked. */
button.action.unlikely { opacity: 0.4; }
@@ -424,11 +437,16 @@ textarea { resize: vertical; }
.event[data-type="poo"] .dot { background: var(--poo); }
.event[data-type="weight"] .dot { background: var(--weight); }
.event[data-type="training"] .dot { background: var(--training); }
.event[data-type="note"] .dot { background: var(--note); }
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
.event .label { font-weight: 600; min-width: 110px; }
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
/* Notes log rows: a date instead of a time-of-day, then the note text. */
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
.event .note-text { flex: 1; white-space: pre-wrap; overflow-wrap: anywhere; }
.empty {
color: var(--muted);
text-align: center;
@@ -644,7 +662,16 @@ dialog menu {
.weight-summary .stat-value.up { color: var(--gain); }
.weight-summary .stat-value.down { color: var(--danger); }
.ww.weight-ww { cursor: pointer; }
.ww.weight-ww .ww-dur { text-align: right; }
/* Keep each weight row to one line: the date/age column shrinks and ellipses,
the weight value stays a fixed size, right-aligned and always visible. */
.ww.weight-ww .ww-range {
min-width: 0;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ww.weight-ww .ww-dur { flex: 0 0 auto; text-align: right; }
.legend {
display: flex;
@@ -665,6 +692,15 @@ dialog menu {
.lg.poo .sw { background: var(--poo); }
.lg.eat .sw { background: var(--eat); }
/* Interactive legend: each item is a checkbox that toggles its metric. */
.legend-toggle label.lg { cursor: pointer; user-select: none; }
.legend-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
.legend-toggle label.pee input { accent-color: var(--pee); }
.legend-toggle label.poo input { accent-color: var(--poo); }
.legend-toggle label.eat input { accent-color: var(--eat); }
/* Dim an unchecked item so it's clear its bars are hidden. */
.legend-toggle label.lg:has(input:not(:checked)) { opacity: 0.5; }
/* ---------- auth (login / register) ---------- */
.auth-screen {
position: fixed;
@@ -1328,3 +1364,34 @@ section.collapsed > :not(h2) { display: none; }
color: var(--muted);
text-align: center;
}
/* ---- pee/poo confetti ---- */
#confetti-layer {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 9999;
}
.confetti-piece {
position: absolute;
line-height: 1;
will-change: transform, opacity;
animation-name: potty-firework;
animation-timing-function: ease-out;
/* `both` so the 0% state (invisible, at the bottom) also applies during the
per-piece launch delay — no flash before it takes off. */
animation-fill-mode: both;
}
/* Launch up from the bottom, rise to a peak while spreading sideways, slowing
(ease-out) and fading out as it reaches the top — a firework fountain.
Distances come from JS custom props. */
@keyframes potty-firework {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5) rotate(0deg); }
10% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--peakY))) scale(1) rotate(var(--rot)); }
}
@media (prefers-reduced-motion: reduce) {
.confetti-piece { display: none; }
}