feat(fitness/active): rail + focus card layout
Redesign the active-workout page around a left-rail timeline and a focus card on the right. The rail owns the workout title, pause, elapsed time, sync indicator, progress bar, and a reorderable chip per exercise (drag to reorder, × to delete, starting-weight hint so you know what to rack, green checkmark when complete). Main stage holds a hero focus card for the active exercise plus its SetTable. - New WorkoutRail.svelte and WorkoutFocusCard.svelte - Active exercise pinned to top of the scrollable rail (mobile only) - Desktop: rail grows freely; mobile: compact vertical stack - Finish + cancel share one row; cancel is a ghost action - Drop the old sticky bottombar; its controls moved into the rail - ExerciseName gains `plain` prop to opt out of the detail link - Active workout route joins the 1400px max-width whitelist
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||
|
||||
let { exerciseId } = $props();
|
||||
let { exerciseId, plain = false } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
|
||||
@@ -11,7 +11,11 @@
|
||||
</script>
|
||||
|
||||
{#if exercise}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{#if plain}
|
||||
<span class="exercise-plain">{exercise.localName}</span>
|
||||
{:else}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="exercise-unknown">Unknown Exercise</span>
|
||||
{/if}
|
||||
@@ -25,6 +29,10 @@
|
||||
.exercise-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.exercise-plain {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.exercise-unknown {
|
||||
color: var(--nord11);
|
||||
font-style: italic;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script>
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
|
||||
/** @type {{
|
||||
* exerciseId: string,
|
||||
* bodyPart?: string | null,
|
||||
* equipment?: string | null,
|
||||
* detailsHref?: string | null,
|
||||
* detailsLabel?: string,
|
||||
* exerciseIndex: number,
|
||||
* totalExercises: number,
|
||||
* sets: Array<{ completed?: boolean }>,
|
||||
* activeSetIdx: number,
|
||||
* labels: { exerciseOf: (i: number, n: number) => string, setOf: (i: number, n: number) => string, done: (n: number) => string },
|
||||
* }} */
|
||||
let {
|
||||
exerciseId,
|
||||
bodyPart = null,
|
||||
equipment = null,
|
||||
detailsHref = null,
|
||||
detailsLabel = 'Exercise details',
|
||||
exerciseIndex,
|
||||
totalExercises,
|
||||
sets,
|
||||
activeSetIdx,
|
||||
labels
|
||||
} = $props();
|
||||
|
||||
const totalSets = $derived(sets.length);
|
||||
const doneSets = $derived(sets.filter((s) => s.completed).length);
|
||||
const allDone = $derived(totalSets > 0 && doneSets === totalSets);
|
||||
</script>
|
||||
|
||||
<section class="focus-card" aria-label="Current exercise">
|
||||
<header class="focus-eyebrow">
|
||||
<span class="focus-step">{labels.exerciseOf(exerciseIndex + 1, totalExercises)}</span>
|
||||
{#if bodyPart}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{bodyPart}</span>
|
||||
{/if}
|
||||
{#if equipment}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{equipment}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="focus-name-row">
|
||||
<h2 class="focus-name"><ExerciseName {exerciseId} plain /></h2>
|
||||
{#if detailsHref}
|
||||
<a class="focus-details" href={detailsHref} aria-label={detailsLabel} title={detailsLabel}>
|
||||
<ChevronRight size={18} strokeWidth={2.2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="focus-progress">
|
||||
<span class="focus-set-label" class:complete={allDone}>
|
||||
{allDone ? labels.done(totalSets) : labels.setOf(activeSetIdx + 1, totalSets)}
|
||||
</span>
|
||||
<span class="focus-dots" aria-hidden="true">
|
||||
{#each sets as s, si (si)}
|
||||
<span
|
||||
class="focus-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.focus-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem 1.25rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
||||
.focus-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.focus-step {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.focus-meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-dot-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Big display name */
|
||||
.focus-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-details {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 100px;
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: background 140ms, color 140ms, transform 140ms;
|
||||
}
|
||||
.focus-details:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Set progress line */
|
||||
.focus-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.focus-set-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-set-label.complete {
|
||||
color: var(--nord14);
|
||||
}
|
||||
.focus-dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.focus-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background 180ms, transform 180ms, border-color 180ms;
|
||||
}
|
||||
.focus-dot.filled {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.focus-dot.current {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 55%);
|
||||
border-color: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
animation: focus-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes focus-dot-pulse {
|
||||
0%, 100% { transform: scale(1.25); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.focus-card {
|
||||
padding: 0.9rem 1rem 0.85rem;
|
||||
}
|
||||
.focus-name {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,687 @@
|
||||
<script>
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
|
||||
/**
|
||||
* @typedef {{ exerciseId: string, sets: Array<{ completed?: boolean }> }} RailExercise
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* exercises: RailExercise[],
|
||||
* activeIdx: number,
|
||||
* activeSetIdx: number,
|
||||
* elapsedLabel: string,
|
||||
* paused?: boolean,
|
||||
* syncStatus?: string,
|
||||
* setsDone: number,
|
||||
* setsTotal: number,
|
||||
* addLabel: string,
|
||||
* pauseLabel?: string,
|
||||
* resumeLabel?: string,
|
||||
* removeLabel?: string,
|
||||
* previousData?: Record<string, Array<{ weight?: number | null, reps?: number | null }>>,
|
||||
* weightUnit?: string,
|
||||
* onPauseToggle?: () => void,
|
||||
* onFocus: (idx: number) => void,
|
||||
* onAddExercise: () => void,
|
||||
* onRemove?: (idx: number) => void,
|
||||
* onReorder?: (fromIdx: number, toIdx: number) => void,
|
||||
* }} */
|
||||
let {
|
||||
exercises,
|
||||
activeIdx,
|
||||
activeSetIdx,
|
||||
elapsedLabel,
|
||||
paused = false,
|
||||
syncStatus = 'idle',
|
||||
setsDone,
|
||||
setsTotal,
|
||||
addLabel,
|
||||
pauseLabel = 'Pause',
|
||||
resumeLabel = 'Resume',
|
||||
removeLabel = 'Remove exercise',
|
||||
previousData = {},
|
||||
weightUnit = 'kg',
|
||||
title,
|
||||
onPauseToggle,
|
||||
onFocus,
|
||||
onAddExercise,
|
||||
onRemove,
|
||||
onReorder
|
||||
} = $props();
|
||||
|
||||
/** Drag-and-drop state */
|
||||
/** @type {number | null} */
|
||||
let draggedIdx = $state(null);
|
||||
/** @type {number | null} */
|
||||
let dragOverIdx = $state(null);
|
||||
|
||||
/** @type {HTMLOListElement | null} */
|
||||
let listEl = $state(null);
|
||||
|
||||
// Keep the active chip at the top of the scrollable list so the user sees current + the next two
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const idx = activeIdx;
|
||||
if (idx < 0) return;
|
||||
const items = listEl.querySelectorAll('.rail-item');
|
||||
const target = /** @type {HTMLElement | undefined} */ (items[idx]);
|
||||
if (!target) return;
|
||||
// Use scrollTop directly to keep the scroll local to the list (avoid page scroll)
|
||||
const listTop = listEl.getBoundingClientRect().top;
|
||||
const itemTop = target.getBoundingClientRect().top;
|
||||
listEl.scrollTo({ top: listEl.scrollTop + (itemTop - listTop), behavior: 'smooth' });
|
||||
});
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragStart(e, idx) {
|
||||
draggedIdx = idx;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires data to be set to initiate a drag
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragOver(e, idx) {
|
||||
if (draggedIdx == null || draggedIdx === idx) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
dragOverIdx = idx;
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDrop(e, idx) {
|
||||
e.preventDefault();
|
||||
if (draggedIdx != null && draggedIdx !== idx && onReorder) {
|
||||
onReorder(draggedIdx, idx);
|
||||
}
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
const progressPct = $derived(setsTotal > 0 ? (setsDone / setsTotal) * 100 : 0);
|
||||
|
||||
/**
|
||||
* What to rack: starting weight × reps for the first set.
|
||||
* Falls back to the previous session's first set if the current plan is blank.
|
||||
* @param {RailExercise} ex
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function startingLoadLabel(ex) {
|
||||
const first = ex.sets[0];
|
||||
const prev = previousData[ex.exerciseId]?.[0];
|
||||
/** @type {number | null | undefined} */
|
||||
const w = (first && typeof first === 'object' && 'weight' in first ? /** @type {any} */(first).weight : null) ?? prev?.weight;
|
||||
/** @type {number | null | undefined} */
|
||||
const r = (first && typeof first === 'object' && 'reps' in first ? /** @type {any} */(first).reps : null) ?? prev?.reps;
|
||||
if (w != null && w > 0 && r != null && r > 0) return `${w} ${weightUnit} × ${r}`;
|
||||
if (w != null && w > 0) return `${w} ${weightUnit}`;
|
||||
if (r != null && r > 0) return `× ${r}`;
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="workout-rail" aria-label="Workout overview">
|
||||
<header class="rail-header">
|
||||
{#if title}
|
||||
<div class="rail-title">{@render title()}</div>
|
||||
{/if}
|
||||
<div class="rail-timer-row">
|
||||
<button
|
||||
class="rail-pause"
|
||||
onclick={() => onPauseToggle?.()}
|
||||
aria-label={paused ? resumeLabel : pauseLabel}
|
||||
type="button"
|
||||
>
|
||||
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
|
||||
</button>
|
||||
<span class="rail-elapsed" class:paused>{elapsedLabel}</span>
|
||||
<span class="rail-sync"><SyncIndicator status={syncStatus} /></span>
|
||||
</div>
|
||||
<div class="rail-progress">
|
||||
<div class="rail-progress-bar">
|
||||
<div class="rail-progress-fill" style:width="{progressPct}%"></div>
|
||||
</div>
|
||||
<span class="rail-progress-label">{setsDone}<span class="rail-progress-sep">/</span>{setsTotal}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol class="rail-list" bind:this={listEl}>
|
||||
{#each exercises as ex, i (i)}
|
||||
{@const isActive = i === activeIdx}
|
||||
{@const done = ex.sets.filter((s) => s.completed).length}
|
||||
{@const complete = done === ex.sets.length && ex.sets.length > 0}
|
||||
{@const load = startingLoadLabel(ex)}
|
||||
{@const isDragging = draggedIdx === i}
|
||||
{@const isDragOver = dragOverIdx === i && draggedIdx !== i}
|
||||
{@const dropAbove = isDragOver && (draggedIdx ?? i) > i}
|
||||
{@const dropBelow = isDragOver && (draggedIdx ?? i) < i}
|
||||
<li
|
||||
class="rail-item"
|
||||
class:dragging={isDragging}
|
||||
class:drop-above={dropAbove}
|
||||
class:drop-below={dropBelow}
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
draggable={onReorder ? 'true' : undefined}
|
||||
ondragstart={(e) => onDragStart(e, i)}
|
||||
ondragover={(e) => onDragOver(e, i)}
|
||||
ondrop={(e) => onDrop(e, i)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<button
|
||||
class="rail-chip"
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
onclick={() => onFocus(i)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<span class="rail-chip-index">{i + 1}</span>
|
||||
<span class="rail-chip-body">
|
||||
<span class="rail-chip-name"><ExerciseName exerciseId={ex.exerciseId} plain /></span>
|
||||
{#if load}
|
||||
<span class="rail-chip-load" aria-label="Starting load">{load}</span>
|
||||
{/if}
|
||||
<span class="rail-chip-dots" aria-hidden="true">
|
||||
{#each ex.sets as s, si (si)}
|
||||
<span
|
||||
class="rail-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={isActive && si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
{#if complete}
|
||||
<span class="rail-chip-count done" aria-label="Exercise complete">
|
||||
<Check size={14} strokeWidth={2.8} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="rail-chip-count">{done}/{ex.sets.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if onRemove}
|
||||
<button
|
||||
class="rail-chip-remove"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(i); }}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
type="button"
|
||||
draggable="false"
|
||||
ondragstart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X size={14} strokeWidth={2.6} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<button class="rail-add" onclick={onAddExercise} type="button">
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
<span>{addLabel}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workout-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1rem 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Header: elapsed + progress */
|
||||
.rail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.25rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.rail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-title :global(input) {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.1rem 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
outline: none;
|
||||
}
|
||||
.rail-title :global(input::placeholder) {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-title :global(input:focus) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-pause {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 140ms, color 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-pause:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.rail-pause:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: 1;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-elapsed.paused {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.rail-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--color-primary), transparent 40%), var(--color-primary));
|
||||
transition: width 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.rail-progress-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.rail-progress-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
margin-inline: 0.1rem;
|
||||
}
|
||||
|
||||
/* Chip list */
|
||||
.rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 100px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-text-tertiary), transparent 50%);
|
||||
}
|
||||
|
||||
/* Row wrapper holds chip + remove button, carries drag state */
|
||||
.rail-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-item[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
.rail-item[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.rail-item.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.rail-item.drop-above::before,
|
||||
.rail-item.drop-below::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary), transparent 70%);
|
||||
}
|
||||
.rail-item.drop-above::before {
|
||||
top: -3px;
|
||||
}
|
||||
.rail-item.drop-below::after {
|
||||
bottom: -3px;
|
||||
}
|
||||
|
||||
.rail-chip {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.55rem 0.55rem 0.45rem;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-chip:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.rail-chip:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.rail-chip.active {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 82%);
|
||||
border-color: transparent;
|
||||
}
|
||||
.rail-chip.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 76%);
|
||||
}
|
||||
.rail-chip.complete {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.rail-chip.complete.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* × remove — overlays the set counter on hover (same spot) */
|
||||
.rail-chip-remove {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.4rem;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 100px;
|
||||
color: var(--nord11);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms, background 140ms, transform 120ms;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-item:hover .rail-chip-remove,
|
||||
.rail-chip-remove:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.rail-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--nord11), transparent 82%);
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
.rail-chip-remove:active {
|
||||
transform: translateY(-50%) scale(0.94);
|
||||
}
|
||||
|
||||
.rail-chip-index {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.rail-chip.active .rail-chip-index {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rail-chip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-chip-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Starting weight hint — "what to rack" */
|
||||
.rail-chip-load {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-load {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-chip.complete .rail-chip-load {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, currentColor, transparent 60%);
|
||||
}
|
||||
|
||||
.rail-chip-dots {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rail-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-border);
|
||||
transition: background 180ms, transform 180ms;
|
||||
}
|
||||
.rail-dot.filled {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 15%);
|
||||
}
|
||||
.rail-dot.current {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.3);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* Set counter — visible by default, fades out when × takes over on hover */
|
||||
.rail-chip-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.55rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-chip.active .rail-chip-count {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
/* Completed exercise: matches the set-complete check button from SetTable */
|
||||
.rail-chip-count.done {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--nord14);
|
||||
background: var(--nord14);
|
||||
color: white;
|
||||
}
|
||||
.rail-item:hover .rail-chip-count {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Add exercise button */
|
||||
.rail-add {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms, color 140ms;
|
||||
}
|
||||
.rail-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow viewports: vertical list, compact chip, dots inline next to name */
|
||||
@media (max-width: 899px) {
|
||||
.workout-rail {
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0.75rem 0.6rem;
|
||||
}
|
||||
/* Title full-width row, timer + progress share a second row */
|
||||
.rail-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"title title"
|
||||
"timer progress";
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.5rem;
|
||||
padding: 0 0.15rem 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-title { grid-area: title; }
|
||||
.rail-timer-row {
|
||||
grid-area: timer;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: initial;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.rail-progress {
|
||||
grid-area: progress;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Dots jump up next to the name; load sits below on its own row */
|
||||
.rail-chip-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"name dots"
|
||||
"load load";
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-chip-name { grid-area: name; }
|
||||
.rail-chip-dots { grid-area: dots; flex-wrap: nowrap; }
|
||||
.rail-chip-load { grid-area: load; }
|
||||
.rail-chip {
|
||||
padding: 0.5rem 0.55rem 0.5rem 0.4rem;
|
||||
}
|
||||
/* Scrollable only on mobile — desktop lets the rail grow */
|
||||
.rail-list {
|
||||
max-height: 10.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Smaller completion checkmark */
|
||||
.rail-chip-count {
|
||||
min-width: 1.25rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.rail-chip-count.done {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.rail-chip-count.done :global(svg) {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
.rail-add {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user