Files
homepage/src/routes/fitness/[measure=fitnessMeasure]/body-parts/+page.svelte
T
Alexander 58b3d4b478
CI / update (push) Successful in 1m6s
fix(fitness): fit body-parts wizard to viewport and tint thigh SVG
Cap shell height to viewport minus header so the bottombar stays visible,
allow the stage to scroll internally, and swap the thigh diagram to a
mask-tinted SVG that tracks the text-primary color across themes.
2026-04-20 23:03:43 +02:00

1342 lines
39 KiB
Svelte

<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Minus, Plus, X, ArrowLeft, ArrowRight, Check, Ruler, CopyPlus, TrendingUp } from '@lucide/svelte';
import { fly, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
import DatePicker from '$lib/components/DatePicker.svelte';
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
/** @typedef {{ key: string, labelKey: string, img: string | null, paired: boolean, tipKey: string, dbSingle?: string, dbLeft?: string, dbRight?: string }} Step */
/** @type {Step[]} */
const steps = [
{ key: 'neck', labelKey: 'neck', img: 'neck.png', paired: false, tipKey: 'measure_tip_neck', dbSingle: 'neck' },
{ key: 'shoulders', labelKey: 'shoulders', img: 'back.png', paired: false, tipKey: 'measure_tip_shoulders', dbSingle: 'shoulders' },
{ key: 'chest', labelKey: 'chest', img: 'shoulders.png', paired: false, tipKey: 'measure_tip_chest', dbSingle: 'chest' },
{ key: 'biceps', labelKey: 'l_bicep', img: 'bicep.png', paired: true, tipKey: 'measure_tip_biceps', dbLeft: 'leftBicep', dbRight: 'rightBicep' },
{ key: 'forearms', labelKey: 'l_forearm', img: null, paired: true, tipKey: 'measure_tip_forearms', dbLeft: 'leftForearm', dbRight: 'rightForearm' },
{ key: 'waist', labelKey: 'waist', img: 'waist.png', paired: false, tipKey: 'measure_tip_waist', dbSingle: 'waist' },
{ key: 'hips', labelKey: 'hips', img: 'hips.png', paired: false, tipKey: 'measure_tip_hips', dbSingle: 'hips' },
{ key: 'thighs', labelKey: 'l_thigh', img: 'thigh.svg', paired: true, tipKey: 'measure_tip_thighs', dbLeft: 'leftThigh', dbRight: 'rightThigh' },
{ key: 'calves', labelKey: 'calves', img: 'calves.png', paired: true, tipKey: 'measure_tip_calves', dbLeft: 'leftCalf', dbRight: 'rightCalf' }
];
/** @param {Step} s */
function stepLabel(s) {
if (!s.paired) return t(s.labelKey, lang);
return t(s.key, lang);
}
/** Sorted-ascending clean list of past body-part records */
const past = $derived(
(data.measurements ?? [])
.slice()
.sort((/** @type {any} */ a, /** @type {any} */ b) => new Date(a.date).getTime() - new Date(b.date).getTime())
);
/** @param {Step} s */
function historyFor(s) {
if (s.paired) {
return past
.filter((/** @type {any} */ m) => m.measurements?.[s.dbLeft] != null || m.measurements?.[s.dbRight] != null)
.map((/** @type {any} */ m) => ({
date: m.date,
left: m.measurements?.[s.dbLeft] ?? null,
right: m.measurements?.[s.dbRight] ?? null
}));
}
return past
.filter((/** @type {any} */ m) => m.measurements?.[s.dbSingle] != null)
.map((/** @type {any} */ m) => ({ date: m.date, value: m.measurements[s.dbSingle] }));
}
/** @type {Record<string, any>} */
const initial = {};
for (const s of steps) initial[s.key] = s.paired ? { left: '', right: '', same: true } : '';
let values = $state(initial);
let idx = $state(0);
let direction = $state(1);
let formDate = $state(new Date().toISOString().slice(0, 10));
let saving = $state(false);
const total = steps.length;
const step = $derived(steps[idx] ?? steps[0]);
const done = $derived(idx >= total);
/** @param {string} key @param {'left'|'right'|null} side @param {number} delta */
function bump(key, side, delta) {
if (side) {
const cur = Number(values[key][side]) || 0;
values[key][side] = String(Math.round((cur + delta) * 10) / 10);
} else {
const cur = Number(values[key]) || 0;
values[key] = String(Math.round((cur + delta) * 10) / 10);
}
}
/** @param {WheelEvent} e @param {string} key @param {'left'|'right'|null} side */
function onWheel(e, key, side) {
if (!e.deltaY) return;
e.preventDefault();
bump(key, side, e.deltaY < 0 ? 0.1 : -0.1);
}
function next() { direction = 1; if (idx < total) idx += 1; }
function back() { direction = -1; if (idx > 0) idx -= 1; }
function skip() {
const s = steps[idx];
values[s.key] = s.paired ? { left: '', right: '', same: true } : '';
next();
}
/** @param {string} key */
function copyLtoR(key) { values[key].right = values[key].left; }
/** @param {number} i */
function jumpTo(i) {
direction = i > idx ? 1 : -1;
idx = i;
}
/** @param {Step} s */
function isFilled(s) {
const v = values[s.key];
if (s.paired) return !!(v.left || v.right);
return !!v;
}
/** @param {Step} s */
function formatValue(s) {
const v = values[s.key];
if (s.paired) {
if (v.same) return v.left ? `${v.left} cm` : '—';
if (v.left && v.right) return `L ${v.left} · R ${v.right}`;
if (v.left) return `L ${v.left}`;
if (v.right) return `R ${v.right}`;
return '—';
}
return v ? `${v} cm` : '—';
}
/** @param {KeyboardEvent} e */
function onkey(e) {
if (done) {
if (e.key === 'ArrowLeft') { e.preventDefault(); idx = total - 1; direction = -1; }
return;
}
const tag = /** @type {HTMLElement|null} */ (e.target)?.tagName;
const inInput = tag === 'INPUT';
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); next(); }
else if (e.key === 'ArrowRight' && !inInput) { e.preventDefault(); next(); }
else if (e.key === 'ArrowLeft' && !inInput) { e.preventDefault(); back(); }
else if ((e.key === 's' || e.key === 'S') && !inInput) { e.preventDefault(); skip(); }
}
const flyOpts = $derived({ x: direction * 40, duration: 260, easing: cubicOut });
async function save() {
/** @type {Record<string, number>} */
const ms = {};
for (const s of steps) {
const v = values[s.key];
if (s.paired) {
const l = Number(v.left);
if (v.left !== '' && isFinite(l)) ms[/** @type {string} */ (s.dbLeft)] = l;
if (v.same) {
if (v.left !== '' && isFinite(l)) ms[/** @type {string} */ (s.dbRight)] = l;
} else {
const r = Number(v.right);
if (v.right !== '' && isFinite(r)) ms[/** @type {string} */ (s.dbRight)] = r;
}
} else {
const n = Number(v);
if (v !== '' && isFinite(n)) ms[/** @type {string} */ (s.dbSingle)] = n;
}
}
if (Object.keys(ms).length === 0) {
toast.error(t('no_body_parts_selected', lang));
return;
}
saving = true;
try {
const res = await fetch('/api/fitness/measurements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: formDate, measurements: ms })
});
if (res.ok) {
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
await goto(`/fitness/${measureSlug}`);
} else {
const err = await res.json().catch(() => null);
toast.error(err?.error ?? 'Failed to save measurement');
}
} catch { toast.error('Failed to save measurement'); }
saving = false;
}
function exit() {
goto(`/fitness/${measureSlug}`);
}
// ----- Chart -----
const CHART_W = 340;
const CHART_H = 120;
const CHART_PAD = { t: 12, r: 14, b: 20, l: 10 };
/** @param {Step} s */
function chartSeries(s) {
const h = historyFor(s);
const v = values[s.key];
/** @type {Array<{name: string, color: string, points: {date: string, value: number}[], pending: number | null}>} */
const series = [];
if (s.paired) {
const leftPoints = h.filter((/** @type {any} */ r) => r.left != null).map((/** @type {any} */ r) => ({ date: r.date, value: r.left }));
const rightPoints = h.filter((/** @type {any} */ r) => r.right != null).map((/** @type {any} */ r) => ({ date: r.date, value: r.right }));
const pendL = Number(v.left);
const pendR = v.same ? Number(v.left) : Number(v.right);
series.push({ name: 'L', color: 'var(--color-primary)', points: leftPoints, pending: isFinite(pendL) && v.left !== '' ? pendL : null });
series.push({ name: 'R', color: 'var(--orange)', points: rightPoints, pending: isFinite(pendR) && (v.same ? v.left : v.right) !== '' ? pendR : null });
} else {
const p = h.map((/** @type {any} */ r) => ({ date: r.date, value: r.value }));
const pend = Number(v);
series.push({ name: '', color: 'var(--color-primary)', points: p, pending: isFinite(pend) && v !== '' ? pend : null });
}
return series;
}
const chart = $derived.by(() => {
const series = chartSeries(step);
const hasHistory = series.some((s) => s.points.length > 0);
const hasPending = series.some((s) => s.pending != null);
if (!hasHistory && !hasPending) {
return { empty: true, series: [], axis: null };
}
const allValues = series.flatMap((s) => [
...s.points.map((p) => p.value),
...(s.pending != null ? [s.pending] : [])
]).filter((n) => isFinite(n));
const min = Math.min(...allValues);
const max = Math.max(...allValues);
const span = max - min;
const pad = span === 0 ? Math.max(0.5, max * 0.02) : span * 0.25;
const yMin = min - pad;
const yMax = max + pad;
const longestSeriesLen = Math.max(...series.map((s) => s.points.length));
const pointCount = longestSeriesLen + (hasPending ? 1 : 0);
const innerW = CHART_W - CHART_PAD.l - CHART_PAD.r;
const innerH = CHART_H - CHART_PAD.t - CHART_PAD.b;
/** @param {number} i @param {number} total */
const xAt = (i, total) =>
CHART_PAD.l + (total <= 1 ? innerW / 2 : (i / (total - 1)) * innerW);
/** @param {number} v */
const yAt = (v) =>
CHART_PAD.t + (1 - (v - yMin) / (yMax - yMin || 1)) * innerH;
const rendered = series.map((s) => {
const hist = s.points.map((p, i) => ({
x: xAt(i, pointCount),
y: yAt(p.value),
value: p.value,
date: p.date
}));
const pending =
s.pending != null
? { x: xAt(longestSeriesLen, pointCount), y: yAt(s.pending), value: s.pending }
: null;
const all = [...hist, ...(pending ? [pending] : [])];
const linePath = all.length > 0 ? 'M' + all.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ') : '';
const baseY = CHART_PAD.t + innerH;
const areaPath = all.length > 0
? `M ${all[0].x.toFixed(1)},${baseY.toFixed(1)} L ` +
all.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ') +
` L ${all[all.length - 1].x.toFixed(1)},${baseY.toFixed(1)} Z`
: '';
return { name: s.name, color: s.color, linePath, areaPath, hist, pending };
});
return {
empty: false,
series: rendered,
axis: {
yMin: yMin.toFixed(1),
yMax: yMax.toFixed(1),
lastDate: series.find((s) => s.points.length > 0)?.points.at(-1)?.date ?? null
}
};
});
/** @param {string | null | undefined} d */
function shortDate(d) {
if (!d) return '';
const dt = new Date(d);
return dt.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { month: 'short', day: 'numeric' });
}
/** @param {string} s @param {Record<string,string|number>} vars */
function fmt(s, vars) {
return s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
}
</script>
<svelte:window onkeydown={onkey} />
<svelte:head><title>{t('body_parts', lang)} - Bocken</title></svelte:head>
<div class="shell" class:is-done={done} data-fitness-fullbleed>
<aside class="rail" aria-label={t('body_parts', lang)}>
<div class="rail-header">
<span class="rail-eyebrow">{t('body_parts', lang)}</span>
<span class="rail-count">{steps.filter(isFilled).length}/{total}</span>
</div>
<ol class="rail-list">
{#each steps as s, i (s.key)}
{@const filled = isFilled(s)}
<li>
<button
type="button"
class="rail-item"
class:active={i === idx && !done}
class:filled
onclick={() => jumpTo(i)}
>
<span class="rail-dot" aria-hidden="true">
{#if filled}<Check size={11} strokeWidth={3} />{/if}
</span>
<span class="rail-label">{stepLabel(s)}</span>
<span class="rail-value">{formatValue(s)}</span>
</button>
</li>
{/each}
<li>
<button
type="button"
class="rail-item review"
class:active={done}
onclick={() => { direction = 1; idx = total; }}
>
<span class="rail-dot" aria-hidden="true"><Check size={11} strokeWidth={3} /></span>
<span class="rail-label">{t('review_save', lang)}</span>
<span class="rail-value"></span>
</button>
</li>
</ol>
</aside>
<header class="topbar">
<div class="progress" aria-label="Progress">
{#each steps as s, i (s.key)}
<span class="dot" class:active={i === idx && !done} class:past={i < idx || done}></span>
{/each}
</div>
<button class="icon-x" aria-label={t('exit', lang)} onclick={exit}>
<X size={18} />
</button>
</header>
<main class="stage">
{#if !done}
{#key step.key}
<section class="card" in:fly={flyOpts}>
<div class="hero">
{#if step.img}
{#if step.img.endsWith('.svg')}
<div
class="hero-pic hero-svg"
style="--hero-svg-src: url(/fitness/measure/{step.img})"
role="img"
aria-label={stepLabel(step)}
></div>
{:else}
<img src="/fitness/measure/{step.img}" alt="" class="hero-pic" />
{/if}
{:else}
<div class="hero-placeholder" aria-hidden="true">
<Ruler size={72} strokeWidth={1.4} />
<span class="placeholder-label">{stepLabel(step)}</span>
</div>
{/if}
</div>
<div class="meta">
<span class="eyebrow">{fmt(t('step_n_of_m', lang), { n: idx + 1, m: total })}</span>
<h1 class="title">{stepLabel(step)}</h1>
<p class="tip">{t(step.tipKey, lang)}</p>
</div>
{#if step.paired}
{@const pv = values[step.key]}
{#if pv.same}
<div class="stepper" onwheel={(e) => onWheel(e, step.key, 'left')}>
<button type="button" class="step-btn" onclick={() => bump(step.key, 'left', -0.1)} aria-label="-0.1">
<Minus size={20} />
</button>
<div class="num-wrap">
<input type="number" step="0.1" bind:value={pv.left} placeholder="—" inputmode="decimal" />
<span class="unit">cm</span>
</div>
<button type="button" class="step-btn" onclick={() => bump(step.key, 'left', 0.1)} aria-label="+0.1">
<Plus size={20} />
</button>
</div>
{:else}
<div class="split">
<div class="stepper compact" onwheel={(e) => onWheel(e, step.key, 'left')}>
<span class="side-tag" style="--side-color: var(--color-primary)">L</span>
<button type="button" class="step-btn sm" onclick={() => bump(step.key, 'left', -0.1)} aria-label="L -0.1">
<Minus size={14} />
</button>
<input type="number" step="0.1" bind:value={pv.left} placeholder="—" inputmode="decimal" />
<span class="unit-sm">cm</span>
<button type="button" class="step-btn sm" onclick={() => bump(step.key, 'left', 0.1)} aria-label="L +0.1">
<Plus size={14} />
</button>
</div>
<div class="stepper compact" onwheel={(e) => onWheel(e, step.key, 'right')}>
<span class="side-tag" style="--side-color: var(--orange)">R</span>
<button type="button" class="step-btn sm" onclick={() => bump(step.key, 'right', -0.1)} aria-label="R -0.1">
<Minus size={14} />
</button>
<input type="number" step="0.1" bind:value={pv.right} placeholder="—" inputmode="decimal" />
<span class="unit-sm">cm</span>
<button type="button" class="step-btn sm" onclick={() => bump(step.key, 'right', 0.1)} aria-label="R +0.1">
<Plus size={14} />
</button>
</div>
<button type="button" class="copy-btn" onclick={() => copyLtoR(step.key)} disabled={!pv.left}>
<CopyPlus size={13} /> {t('copy_l_to_r', lang)}
</button>
</div>
{/if}
<label class="same-toggle">
<input type="checkbox" bind:checked={pv.same} />
<span>{t('same_both_sides', lang)}</span>
</label>
{:else}
<div class="stepper" onwheel={(e) => onWheel(e, step.key, null)}>
<button type="button" class="step-btn" onclick={() => bump(step.key, null, -0.1)} aria-label="-0.1">
<Minus size={20} />
</button>
<div class="num-wrap">
<input type="number" step="0.1" bind:value={values[step.key]} placeholder="—" inputmode="decimal" />
<span class="unit">cm</span>
</div>
<button type="button" class="step-btn" onclick={() => bump(step.key, null, 0.1)} aria-label="+0.1">
<Plus size={20} />
</button>
</div>
{/if}
</section>
{/key}
{:else}
<section class="summary" in:fly={{ y: 16, duration: 320, easing: cubicOut }}>
<div class="check-halo">
<Check size={42} strokeWidth={2.4} />
</div>
<h1 class="title">{t('ready_to_save', lang)}</h1>
<p class="tip">{t('review_numbers', lang)}</p>
<div class="date-row">
<DatePicker bind:value={formDate} {lang} />
</div>
<ul class="summary-list">
{#each steps as s (s.key)}
<li>
<span class="sum-label">{stepLabel(s)}</span>
<span class="sum-val" class:empty={formatValue(s) === '—'}>{formatValue(s)}</span>
</li>
{/each}
</ul>
<div class="summary-actions">
<button type="button" class="ghost" onclick={() => { idx = 0; direction = -1; }}>
<ArrowLeft size={14} /> {t('edit_again', lang)}
</button>
<button type="button" class="nav-btn primary" onclick={save} disabled={saving}>
<Check size={16} /> {saving ? t('saving', lang) : t('save_measurement', lang)}
</button>
</div>
</section>
{/if}
</main>
<aside class="panel" aria-label={t('running_totals', lang)}>
{#if !done}
{#key step.key}
<div class="panel-section chart-section" in:fade={{ duration: 180 }}>
<div class="panel-head">
<TrendingUp size={14} />
<h3 class="panel-title">{fmt(t('over_time', lang), { label: stepLabel(step) })}</h3>
</div>
{#if chart.empty}
<div class="chart-empty">
<span class="chart-empty-dot"></span>
{t('first_measurement_hint', lang)}
</div>
{:else}
<svg class="chart" viewBox="0 0 {CHART_W} {CHART_H}" role="img" aria-label={fmt(t('over_time', lang), { label: stepLabel(step) })}>
<defs>
<linearGradient id="area-grad-primary" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--color-primary)" stop-opacity="0.25" />
<stop offset="100%" stop-color="var(--color-primary)" stop-opacity="0" />
</linearGradient>
<linearGradient id="area-grad-orange" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--orange)" stop-opacity="0.18" />
<stop offset="100%" stop-color="var(--orange)" stop-opacity="0" />
</linearGradient>
</defs>
<line x1={CHART_PAD.l} x2={CHART_W - CHART_PAD.r} y1={CHART_H - CHART_PAD.b} y2={CHART_H - CHART_PAD.b} class="axis-line" />
{#each chart.series as s, i (s.name || 'single')}
<path d={s.areaPath} fill={i === 0 ? 'url(#area-grad-primary)' : 'url(#area-grad-orange)'} />
<path d={s.linePath} stroke={s.color} stroke-width="1.8" fill="none" stroke-linejoin="round" stroke-linecap="round" />
{#each s.hist as d}
<circle cx={d.x} cy={d.y} r="2.5" fill={s.color} class="hist-dot" />
{/each}
{#if s.pending}
<circle cx={s.pending.x} cy={s.pending.y} r="11" fill={s.color} opacity="0.16" class="pulse" />
<circle cx={s.pending.x} cy={s.pending.y} r="4.5" fill={s.color} stroke="var(--color-surface)" stroke-width="2" class="pending-dot" />
{/if}
{/each}
{#if chart.axis}
<text x={CHART_W - 2} y={CHART_PAD.t + 4} text-anchor="end" class="axis-label">{chart.axis.yMax}</text>
<text x={CHART_W - 2} y={CHART_H - CHART_PAD.b - 2} text-anchor="end" class="axis-label">{chart.axis.yMin}</text>
{#if chart.axis.lastDate}
<text x={CHART_PAD.l} y={CHART_H - 4} text-anchor="start" class="axis-label">{shortDate(chart.axis.lastDate)}</text>
{/if}
<text x={CHART_W - CHART_PAD.r} y={CHART_H - 4} text-anchor="end" class="axis-label accent">{t('today_short', lang)}</text>
{/if}
</svg>
{#if chart.series.length > 1}
<div class="legend">
{#each chart.series as s (s.name)}
<span class="legend-item">
<span class="swatch" style:background={s.color}></span>
<span>{s.name}</span>
{#if s.pending != null}
<span class="legend-val">{s.pending.value.toFixed(1)}</span>
{/if}
</span>
{/each}
</div>
{:else if chart.series[0]?.pending != null}
<div class="legend single">
<span class="legend-item">
<span class="swatch" style:background={chart.series[0].color}></span>
<span class="legend-val">{chart.series[0].pending.value.toFixed(1)} cm</span>
<span class="legend-tag">{t('today_short', lang)}</span>
</span>
</div>
{/if}
{/if}
</div>
{/key}
{/if}
<div class="panel-section totals-section">
<div class="panel-head">
<h3 class="panel-title">{t('running_totals', lang)}</h3>
</div>
<ul class="totals">
{#each steps as s, i (s.key)}
<li class:dim={!isFilled(s)} class:focused={i === idx && !done}>
<button type="button" class="totals-item" onclick={() => jumpTo(i)}>
<span class="totals-label">{stepLabel(s)}</span>
<span class="totals-val">{formatValue(s)}</span>
</button>
</li>
{/each}
</ul>
</div>
</aside>
<footer class="bottombar">
{#if !done}
<button type="button" class="ghost" onclick={skip}>{t('skip', lang)}</button>
<div class="kbd-legend" aria-hidden="true">
<span><kbd></kbd><kbd></kbd> {t('kbd_nav', lang)}</span>
<span><kbd></kbd> {t('kbd_next', lang)}</span>
<span><kbd>S</kbd> {t('kbd_skip', lang)}</span>
<span><kbd>scroll</kbd> {t('kbd_wheel', lang)}</span>
</div>
<div class="nav-pair">
<button type="button" class="nav-btn" onclick={back} disabled={idx === 0} aria-label={t('back', lang)}>
<ArrowLeft size={16} />
</button>
<button type="button" class="nav-btn primary" onclick={next}>
{idx === total - 1 ? t('review', lang) : t('next', lang)}
<ArrowRight size={16} />
</button>
</div>
{:else}
<span class="bottom-spacer"></span>
{/if}
</footer>
</div>
<style>
:global(.wrapper:has([data-fitness-fullbleed]) > footer) {
display: none;
}
.shell {
--fitness-header-offset: calc(3rem + 12px + env(safe-area-inset-top, 0px));
height: calc(100svh - var(--fitness-header-offset));
min-height: 0;
display: flex;
flex-direction: column;
background:
radial-gradient(800px 400px at 90% 110%, color-mix(in oklab, var(--color-primary) 8%, transparent), transparent 55%),
var(--color-bg-primary);
color: var(--color-text-primary);
}
.rail { display: none; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
}
.progress {
display: flex;
gap: 0.4rem;
flex: 1;
align-items: center;
}
.dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-border);
transition: all 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dot.past { background: color-mix(in oklab, var(--color-primary) 60%, transparent); }
.dot.active {
width: 22px;
background: var(--color-primary);
}
.icon-x {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms;
}
.icon-x:hover {
color: var(--color-text-primary);
border-color: var(--color-text-tertiary);
}
.stage {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.5rem 1rem 2rem;
overflow-y: auto;
min-height: 0;
}
.card {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.1rem;
}
.hero {
width: 220px;
height: 220px;
display: grid;
place-items: center;
border-radius: 50%;
background:
radial-gradient(closest-side, var(--color-surface), transparent 70%),
var(--color-bg-secondary);
box-shadow: var(--shadow-md);
position: relative;
}
.hero-pic {
width: 150px;
height: 150px;
object-fit: contain;
}
.hero-svg {
mask-image: var(--hero-svg-src);
-webkit-mask-image: var(--hero-svg-src);
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
background-color: var(--color-text-primary);
}
@media (prefers-color-scheme: dark) {
img.hero-pic { filter: invert(1); }
}
:global(:root[data-theme="dark"]) img.hero-pic { filter: invert(1); }
:global(:root[data-theme="light"]) img.hero-pic { filter: none; }
.hero-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
color: var(--color-text-tertiary);
}
.placeholder-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
text-align: center;
}
.eyebrow {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.title {
margin: 0;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
}
.tip {
margin: 0;
max-width: 340px;
font-size: 0.95rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.stepper {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.9rem 1rem;
background: var(--color-surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-md);
}
.step-btn {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
cursor: pointer;
transition: all 150ms;
}
.step-btn:hover {
background: var(--color-bg-elevated);
border-color: var(--color-primary);
}
.step-btn:active {
transform: scale(0.94);
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.step-btn.sm { width: 2.1rem; height: 2.1rem; }
.num-wrap {
display: flex;
align-items: baseline;
gap: 0.25rem;
min-width: 6.5rem;
justify-content: center;
}
.num-wrap input {
width: 5ch;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 2.6rem;
font-weight: 700;
text-align: center;
letter-spacing: -0.02em;
appearance: textfield;
-moz-appearance: textfield;
}
.num-wrap input::-webkit-inner-spin-button,
.num-wrap input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.num-wrap input::placeholder { color: var(--color-text-tertiary); }
.num-wrap input:focus { outline: none; }
.unit {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.split {
display: flex;
flex-direction: column;
gap: 0.55rem;
align-items: stretch;
width: 100%;
max-width: 340px;
}
.stepper.compact {
gap: 0.5rem;
padding: 0.55rem 0.75rem;
justify-content: space-between;
}
.stepper.compact input {
width: 4ch;
font-size: 1.4rem;
border: none;
background: transparent;
color: var(--color-text-primary);
font-weight: 600;
text-align: center;
}
.stepper.compact input:focus { outline: none; }
.side-tag {
font-weight: 700;
font-size: 0.78rem;
letter-spacing: 0.1em;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--side-color, var(--color-primary)) 18%, transparent);
color: var(--side-color, var(--color-text-secondary));
}
.unit-sm {
font-size: 0.72rem;
font-weight: 600;
color: var(--color-text-tertiary);
}
.copy-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.7rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
font-size: 0.7rem;
color: var(--color-text-tertiary);
cursor: pointer;
transition: all 150ms;
}
.copy-btn:hover:not(:disabled) {
color: var(--color-primary);
border-color: var(--color-primary);
border-style: solid;
}
.copy-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.same-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.same-toggle input {
accent-color: var(--color-primary);
width: 0.95rem;
height: 0.95rem;
}
.panel { display: none; }
.bottombar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem 1.5rem;
gap: 0.75rem;
max-width: 520px;
width: 100%;
margin-inline: auto;
}
.ghost {
border: none;
background: transparent;
color: var(--color-text-tertiary);
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: var(--radius-md);
transition: color 150ms;
}
.ghost:hover { color: var(--color-text-primary); }
.nav-pair { display: flex; gap: 0.5rem; }
.nav-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.7rem 1.15rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text-primary);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 150ms;
}
.nav-btn:hover:not(:disabled) { border-color: var(--color-text-tertiary); }
.nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.nav-btn.primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.nav-btn.primary:hover:not(:disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.kbd-legend { display: none; }
.bottom-spacer { flex: 1; }
.summary {
flex: 1;
width: 100%;
max-width: 480px;
margin: 2rem auto 3rem;
padding: 0 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.check-halo {
display: grid;
place-items: center;
width: 76px;
height: 76px;
border-radius: 50%;
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
color: var(--color-primary);
box-shadow: 0 0 0 8px color-mix(in oklab, var(--color-primary) 8%, transparent);
margin-bottom: 0.25rem;
}
.summary .title { font-size: 1.8rem; }
.summary .tip { margin-bottom: 0.5rem; }
.date-row {
display: flex;
justify-content: center;
width: 100%;
}
.summary-list {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
background: var(--color-surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.summary-list li {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.summary-list li:last-child { border-bottom: none; }
.sum-label { font-size: 0.9rem; color: var(--color-text-secondary); }
.sum-val {
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
.sum-val.empty { color: var(--color-text-tertiary); font-weight: 400; }
.summary-actions {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 0.5rem;
}
@media (min-width: 1024px) {
.shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 360px;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
"rail topbar panel"
"rail stage panel"
"rail bottom panel";
height: 100vh;
margin-top: calc(-1 * var(--fitness-header-offset));
}
.rail { grid-area: rail; }
.topbar { grid-area: topbar; padding: calc(1.25rem + var(--fitness-header-offset)) 2rem 0; }
.stage { grid-area: stage; padding: 1.5rem 2rem 2rem; align-items: center; }
.panel { grid-area: panel; }
.bottombar { grid-area: bottom; max-width: none; margin-inline: 0; padding: 1rem 2rem 1.5rem; }
.topbar .progress { visibility: hidden; }
.rail {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border);
padding: 1.5rem 0.75rem 1.5rem 1.5rem;
background: color-mix(in oklab, var(--color-surface) 55%, transparent);
backdrop-filter: blur(8px);
overflow-y: auto;
}
.rail-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1rem;
padding-right: 0.5rem;
}
.rail-eyebrow {
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 700;
color: var(--color-text-tertiary);
}
.rail-count {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
font-weight: 600;
}
.rail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.rail-item {
width: 100%;
display: grid;
grid-template-columns: 22px 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
text-align: left;
cursor: pointer;
border-radius: var(--radius-md);
position: relative;
transition: all 150ms;
}
.rail-item::before {
content: '';
position: absolute;
left: -0.75rem;
top: 20%;
bottom: 20%;
width: 2px;
background: transparent;
border-radius: 999px;
transition: background 150ms;
}
.rail-item:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.rail-item.active {
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
color: var(--color-text-primary);
}
.rail-item.active::before {
background: var(--color-primary);
}
.rail-dot {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-on-primary);
transition: all 200ms;
}
.rail-item.active .rail-dot {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 15%, transparent);
}
.rail-item.filled .rail-dot {
background: var(--color-primary);
border-color: var(--color-primary);
}
.rail-item.review .rail-dot {
border-style: dashed;
border-color: var(--color-text-tertiary);
}
.rail-item.review.active .rail-dot {
background: var(--color-primary);
border-color: var(--color-primary);
}
.rail-label {
font-size: 0.88rem;
font-weight: 600;
}
.rail-value {
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-tertiary);
letter-spacing: 0.01em;
}
.rail-item.filled .rail-value { color: var(--color-text-secondary); }
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
border-left: 1px solid var(--color-border);
padding: 1.5rem 1.25rem;
background: color-mix(in oklab, var(--color-surface) 55%, transparent);
backdrop-filter: blur(8px);
overflow-y: auto;
}
.panel-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0.9rem 1rem;
}
.panel-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.6rem;
color: var(--color-text-tertiary);
}
.panel-title {
margin: 0;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.chart {
width: 100%;
height: auto;
display: block;
}
.chart .axis-line {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 2 3;
}
.chart .axis-label {
font-size: 8.5px;
fill: var(--color-text-tertiary);
font-weight: 600;
letter-spacing: 0.04em;
}
.chart .axis-label.accent {
fill: var(--color-primary);
}
.chart .hist-dot {
opacity: 0.85;
}
.chart .pulse {
animation: pulse-grow 1.8s ease-out infinite;
transform-origin: center;
transform-box: fill-box;
}
.chart .pending-dot {
filter: drop-shadow(0 1px 2px color-mix(in oklab, var(--color-primary) 40%, transparent));
}
.chart-empty {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.25rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
font-style: italic;
}
.chart-empty-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 25%, transparent);
flex-shrink: 0;
}
.legend {
display: flex;
gap: 0.85rem;
margin-top: 0.35rem;
flex-wrap: wrap;
}
.legend.single { justify-content: flex-end; }
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: var(--color-text-secondary);
font-weight: 600;
}
.swatch {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.legend-val {
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
}
.legend-tag {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-primary);
font-weight: 700;
}
.totals {
list-style: none;
margin: 0;
padding: 0;
}
.totals li { border-bottom: 1px dashed var(--color-border); }
.totals li:last-child { border-bottom: none; }
.totals-item {
width: 100%;
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.42rem 0.2rem;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
transition: color 150ms;
}
.totals-item:hover { color: var(--color-text-primary); }
.totals-label {
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.totals-val {
font-size: 0.82rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.totals li.dim .totals-val { color: var(--color-text-tertiary); font-weight: 400; }
.totals li.focused .totals-label {
color: var(--color-primary);
font-weight: 700;
}
.hero { width: 300px; height: 300px; }
.hero-pic { width: 200px; height: 200px; }
.title { font-size: 3.2rem; }
.tip { font-size: 1rem; max-width: 420px; }
.num-wrap input { font-size: 3rem; }
.split {
flex-direction: row;
align-items: center;
max-width: none;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.split .copy-btn { flex-basis: 100%; order: 3; }
.stepper.compact { flex: 1 1 180px; min-width: 180px; }
.kbd-legend {
display: flex;
gap: 1rem;
font-size: 0.68rem;
color: var(--color-text-tertiary);
flex: 1;
justify-content: center;
align-items: center;
}
.kbd-legend span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.kbd-legend kbd {
font-family: inherit;
display: inline-grid;
place-items: center;
min-width: 1.4rem;
height: 1.4rem;
padding: 0 0.35rem;
border: 1px solid var(--color-border);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.65rem;
font-weight: 700;
}
}
@media (min-width: 1400px) {
.shell {
grid-template-columns: 300px minmax(0, 1fr) 400px;
}
.hero { width: 340px; height: 340px; }
.hero-pic { width: 230px; height: 230px; }
.title { font-size: 3.6rem; }
}
@media (max-width: 420px) {
.hero { width: 180px; height: 180px; }
.hero-pic { width: 120px; height: 120px; }
.title { font-size: 2rem; }
}
@keyframes pulse-grow {
0%, 100% { opacity: 0.16; transform: scale(1); }
50% { opacity: 0.32; transform: scale(1.25); }
}
</style>