Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions app/src/app.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@
sizeFor={exportSizeEstimateFor}
bytesFor={renderFileSizeBytesFor}
diskFreeBytes={exportDiskFreeBytes}
usesRiderWeight={app.usesRiderWeight}
testAvailableFor={exportTestAvailableFor}
calibrating={app.calibratingFormat}
oncalibrate={(fmt) => app.calibrateExportEstimate(fmt)}
Expand Down Expand Up @@ -786,13 +787,6 @@
use:windowDrag
class="h-[52px] shrink-0 bg-[var(--panel)] rounded-[10px] flex items-center gap-2 pr-3 pl-[88px] z-50"
>
<!-- Logo chip -->
<span
class="w-5 h-5 shrink-0 rounded-md bg-primary text-[#050505] font-mono font-bold text-xs flex items-center justify-center select-none"
>C</span
>
<div class="h-5 w-px bg-white/[0.07] shrink-0"></div>

<!-- ── Template toolbar ─────────────────────────────────────────────────── -->
<div class="flex items-center gap-1 shrink-0">
<!-- Template picker -->
Expand Down
23 changes: 22 additions & 1 deletion app/src/components/layout/CenterCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import VideoBackdrop from '../canvas/VideoBackdrop.svelte'
import WysiwygLayer from '../canvas/WysiwygLayer.svelte'
import PlaybackControls from '../canvas/PlaybackControls.svelte'
import RiderWeightPrompt from '../ui/RiderWeightPrompt.svelte'
import VideoAlignmentBar from '../canvas/VideoAlignmentBar.svelte'
import { videoStartOnAxis } from '@/lib/videoAlignment.js'
import { isLapMetric } from '@/lib/elementTypes.js'
Expand Down Expand Up @@ -743,11 +744,31 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={clipEl}
class="flex-1 flex items-center justify-center p-6 overflow-hidden"
class="relative flex-1 flex items-center justify-center p-6 overflow-hidden"
style="background: radial-gradient(120% 90% at 50% 0%, rgba(220, 20, 60, 0.09), transparent 58%);"
onwheel={onCanvasWheel}
ondblclick={resetZoom}
>
<!-- Missing rider weight — every W/kg readout in the preview is sitting at
0, which is easy to miss when nothing is selected. It floats over the
canvas rather than sitting above it: taking up layout space would push
the preview down while it's shown and let it snap back when it goes. -->
{#if app.usesRiderWeight && app.hasActivity}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pointer-events-none absolute inset-x-3 top-3 z-20 flex justify-center"
ondblclick={(e) => e.stopPropagation()}
onwheel={(e) => e.stopPropagation()}
>
<RiderWeightPrompt
compact
floating
class="pointer-events-auto w-full max-w-2xl"
message="W/kg is showing 0 — enter your rider weight to see real numbers."
/>
</div>
{/if}

{#if app.config && app.hasActivity}
<!-- Aspect-ratio wrapper — always shown when a template is loaded -->
<div
Expand Down
20 changes: 20 additions & 0 deletions app/src/components/overlays/ExportFormatDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { TriangleAlert } from 'lucide-svelte'
import WindowDragStrip from './WindowDragStrip.svelte'
import Switch from '../ui/Switch.svelte'
import RiderWeightPrompt from '../ui/RiderWeightPrompt.svelte'
import { formatFileSize } from '@/lib/utils.js'

let {
Expand All @@ -22,6 +23,9 @@
bytesFor = null,
// Free bytes on the output volume, or null when unknown
diskFreeBytes = null,
// True when the template renders W/kg — with no rider weight set those
// readouts export as 0, so offer the weight field before rendering.
usesRiderWeight = false,
// (format) => boolean — true when the codec has no same-machine
// measurement yet, so it offers a quick test render
testAvailableFor = null,
Expand Down Expand Up @@ -60,6 +64,13 @@
})

function onKeydown(e) {
// Enter inside a field (the rider-weight prompt) edits, it doesn't render.
const t = e.target
if (
e.key === 'Enter' &&
(t?.tagName === 'INPUT' || t?.tagName === 'SELECT')
)
return
if (e.key === 'Escape') {
e.preventDefault()
oncancel?.()
Expand Down Expand Up @@ -251,6 +262,15 @@
</div>
{/if}

<!-- W/kg with no rider weight: the export would bake in 0.0 for every such
readout, so collect the weight here rather than after the render. -->
{#if usesRiderWeight}
<RiderWeightPrompt
class="mt-4"
message="This template shows W/kg, but no rider weight is set — those readouts will export as 0."
/>
{/if}

<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => oncancel?.()}
Expand Down
25 changes: 2 additions & 23 deletions app/src/components/overlays/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { X } from 'lucide-svelte'
import { formatHomePath } from '../../lib/utils.js'
import Select from '../ui/Select.svelte'
import RiderWeightField from '../ui/RiderWeightField.svelte'

const app = getContext('app')

Expand Down Expand Up @@ -87,29 +88,7 @@
Used only for the W/kg metric. Stored on this device — never saved to
templates.
</p>
<div class="flex items-center gap-1.5">
<input
type="number"
min="0"
step="0.1"
inputmode="decimal"
placeholder="—"
value={app.riderWeight ?? ''}
oninput={(e) => (app.riderWeight = e.target.value)}
class="min-w-0 flex-1 h-7 rounded-[6px] border-0 bg-[var(--panel2)] px-2 text-xs
text-foreground font-mono focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div class="shrink-0 w-20">
<Select
value={app.riderWeightUnit}
options={[
{ value: 'kg', label: 'kg' },
{ value: 'lb', label: 'lb' },
]}
onchange={(v) => (app.riderWeightUnit = v)}
/>
</div>
</div>
<RiderWeightField />
</div>

<!-- Output folder -->
Expand Down
18 changes: 17 additions & 1 deletion app/src/components/panels/ElementList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Square,
Thermometer,
Trash2,
TriangleAlert,
Type,
X,
} from 'lucide-svelte'
Expand All @@ -28,6 +29,11 @@

const app = getContext('app')

// W/kg with no rider weight renders 0 — flag those rows so the problem shows
// up in the list, not just in the selected element's properties.
const missingRiderWeight = (metric) =>
metric === 'power_to_weight' && app.riderWeightKg == null

let addImagePending = $state(false)

// ── Drag state ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -63,7 +69,7 @@
const byId = {}
for (const el of app.config.elements) {
const meta = elementMeta(el)
byId[el.id] = { id: el.id, type: meta.kind, name: meta.name, unit: meta.unit }
byId[el.id] = { id: el.id, type: meta.kind, name: meta.name, unit: meta.unit, metric: el.value }
}
return [...(app.elementLayerOrder ?? [])].reverse().map((id) => byId[id]).filter(Boolean)
})
Expand Down Expand Up @@ -587,6 +593,11 @@
{#if el.unit}
<span class="shrink-0 text-[9px] font-medium px-1 py-0.5 rounded bg-[var(--panel3)] text-[var(--dim)] uppercase tracking-wide">{el.unit === 'imperial' ? 'imp' : el.unit}</span>
{/if}
{#if missingRiderWeight(el.metric)}
<span class="shrink-0 text-[var(--ds-warning)]" title="Rider weight not set — this W/kg readout renders as 0">
<TriangleAlert size={11} />
</span>
{/if}
</button>
<div class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover/el:opacity-100 transition-opacity">
<button
Expand Down Expand Up @@ -651,6 +662,11 @@
{#if el.unit}
<span class="shrink-0 text-[9px] font-medium px-1 py-0.5 rounded bg-[var(--panel3)] text-[var(--dim)] uppercase tracking-wide">{el.unit === 'imperial' ? 'imp' : el.unit}</span>
{/if}
{#if missingRiderWeight(el.metric)}
<span class="shrink-0 text-[var(--ds-warning)]" title="Rider weight not set — this W/kg readout renders as 0">
<TriangleAlert size={11} />
</span>
{/if}
</button>
<div class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover/el:opacity-100 transition-opacity">
<button
Expand Down
10 changes: 10 additions & 0 deletions app/src/components/panels/ElementProperties.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import Switch from '../ui/Switch.svelte'
import Tooltip from '../ui/Tooltip.svelte'
import ColorInput from '../ui/ColorInput.svelte'
import RiderWeightPrompt from '../ui/RiderWeightPrompt.svelte'
import AssetPicker from '../overlays/AssetPicker.svelte'
import * as backend from '../../api/backend.js'
import { defaultColorBands, elementMeta, elementTypeName, LAP_METRICS as ALL_LAP_METRICS, isLapMetric } from '../../lib/elementTypes.js'
Expand Down Expand Up @@ -1170,6 +1171,9 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
options={VALUE_METRIC_OPTIONS}
onchange={(v) => update('value', v)}
/>
{#if item.value === 'power_to_weight'}
<RiderWeightPrompt />
{/if}

{#if isSummaryMetric(item.value)}
<label class="space-y-1 block">
Expand Down Expand Up @@ -1755,6 +1759,9 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
options={METER_METRICS.map((m) => ({ value: m, label: metricLabel(m) }))}
onchange={(v) => update('value', v)}
/>
{#if item.value === 'power_to_weight'}
<RiderWeightPrompt />
{/if}
{#if UNITS_BY_METRIC[item.value]}
<label class="space-y-1 block">
<span class="text-xs text-zinc-500">Unit</span>
Expand Down Expand Up @@ -2013,6 +2020,9 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
options={METER_METRICS.map((m) => ({ value: m, label: metricLabel(m) }))}
onchange={(v) => update('value', v)}
/>
{#if item.value === 'power_to_weight'}
<RiderWeightPrompt />
{/if}
{#if UNITS_BY_METRIC[item.value]}
<label class="space-y-1 block">
<span class="text-xs text-zinc-500">Unit</span>
Expand Down
115 changes: 115 additions & 0 deletions app/src/components/ui/RiderWeightField.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script>
// Rider weight input + unit picker, with the typing rules the W/kg prompts
// depend on: the app-level weight is only written on Enter or blur, never
// per-keystroke. Committing every keystroke would turn the "7" of "75" into a
// real 7 kg weight — silently wrong numbers, and the prompt asking for the
// weight would vanish before the second digit was typed.
import { getContext } from 'svelte'
import Select from './Select.svelte'

const app = getContext('app')

let {
// Called with the committed weight (in the current unit) after a valid entry.
onset = null,
} = $props()

// Plausible rider range in kg. A partially typed number almost always lands
// outside it, so it doubles as the guard against committing half an entry.
const MIN_KG = 30
const MAX_KG = 200
const LB_PER_KG = 1 / 0.45359237

let draft = $state(app.riderWeight == null ? '' : String(app.riderWeight))
let editing = $state(false)
let error = $state(null)

// Follow the weight when it changes elsewhere (Settings, another prompt),
// but never overwrite what the user is in the middle of typing.
$effect(() => {
const weight = app.riderWeight
if (!editing) draft = weight == null ? '' : String(weight)
})

let bounds = $derived(
app.riderWeightUnit === 'lb'
? [Math.round(MIN_KG * LB_PER_KG), Math.round(MAX_KG * LB_PER_KG)]
: [MIN_KG, MAX_KG],
)

function commit() {
const raw = draft.trim()
if (raw === '') {
// Cleared on purpose — drop the weight and go back to prompting.
error = null
app.riderWeight = null
return
}
const entered = Number(raw)
const kg =
app.riderWeightUnit === 'lb' ? entered * 0.45359237 : entered
if (!Number.isFinite(entered) || kg < MIN_KG || kg > MAX_KG) {
error = `Enter a weight between ${bounds[0]} and ${bounds[1]} ${app.riderWeightUnit}.`
return
}
error = null
app.riderWeight = entered
onset?.(entered)
}

function onKeydown(e) {
if (e.key === 'Enter') {
// Don't let Enter reach a dialog that treats it as "confirm".
e.preventDefault()
e.stopPropagation()
commit()
}
}

// Switching units re-reads the same number under the new unit, so re-check it.
function changeUnit(unit) {
app.riderWeightUnit = unit
if (draft.trim() !== '') commit()
}
</script>

<div class="space-y-1">
<div class="flex items-center gap-1.5">
<input
type="number"
min="0"
step="0.1"
inputmode="decimal"
placeholder="—"
aria-label="Rider weight"
aria-invalid={error ? 'true' : undefined}
value={draft}
oninput={(e) => {
draft = e.target.value
error = null
}}
onfocus={() => (editing = true)}
onblur={() => {
editing = false
commit()
}}
onkeydown={onKeydown}
class="w-20 h-7 rounded-[6px] border-0 bg-[var(--panel2)] px-2 text-xs
text-foreground font-mono focus:outline-none focus:ring-1
{error ? 'ring-1 ring-destructive focus:ring-destructive' : 'focus:ring-ring'}"
/>
<div class="shrink-0 w-20">
<Select
value={app.riderWeightUnit}
options={[
{ value: 'kg', label: 'kg' },
{ value: 'lb', label: 'lb' },
]}
onchange={changeUnit}
/>
</div>
</div>
{#if error}
<p class="text-[10px] leading-snug text-destructive">{error}</p>
{/if}
</div>
Loading
Loading