feat: YOLO toggle inline layout; yoloLabel/yoloDescription configurable via config.json

This commit is contained in:
Falkan
2026-03-17 19:50:59 -04:00
parent 78e24017a7
commit e2e53ca44a
4 changed files with 32 additions and 33 deletions

View File

@@ -1,16 +1,6 @@
<script lang="ts"> <script lang="ts">
/** /**
* UnitInput — uncontrolled numeric input with one-way push to parent. * UnitInput — controlled numeric input.
*
* The input owns its own DOM value. We only push changes upward via onchange.
* We deliberately do NOT set value={value} on the input element — that would
* cause Svelte to re-render the input on every parent state update, resetting
* the cursor position and stealing focus mid-typing.
*
* The parent can pass a new `value` prop if it needs to imperatively set the
* input (e.g. Ctrl+click adopts a converted value). We detect that via $effect
* and update the DOM node directly, only when the value actually differs from
* what's currently in the input.
*/ */
let { let {
@@ -21,18 +11,6 @@
onchange: (v: number) => void; onchange: (v: number) => void;
} = $props(); } = $props();
let inputEl = $state<HTMLInputElement | null>(null);
// Sync parent-driven value changes into the DOM — but only when the parent
// is actually setting a new value (e.g. Ctrl+click), not on every keystroke.
$effect(() => {
if (!inputEl) return;
const current = parseFloat(inputEl.value);
if (isNaN(current) || current !== value) {
inputEl.value = String(value);
}
});
function handleInput(e: Event) { function handleInput(e: Event) {
const input = e.currentTarget as HTMLInputElement; const input = e.currentTarget as HTMLInputElement;
const parsed = parseFloat(input.value); const parsed = parseFloat(input.value);
@@ -43,9 +21,9 @@
</script> </script>
<input <input
bind:this={inputEl}
type="number" type="number"
step="any" step="any"
value={value}
oninput={handleInput} oninput={handleInput}
aria-label="Input value" aria-label="Input value"
/> />

View File

@@ -11,6 +11,10 @@ export interface AppConfig {
adminPath: string; adminPath: string;
passwordHash: string; passwordHash: string;
sessionSecret: string; sessionSecret: string;
/** Label shown next to the YOLO mode toggle. Defaults to "YOLO mode". */
yoloLabel?: string;
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */
yoloDescription?: string;
} }
/** Read units.json synchronously. Returns parsed UnitsData. */ /** Read units.json synchronously. Returns parsed UnitsData. */

View File

@@ -1,10 +1,13 @@
import { loadData } from '$lib/server/data'; import { loadData, loadConfig } from '$lib/server/data';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => { export const load: PageServerLoad = () => {
const data = loadData(); const data = loadData();
const config = loadConfig();
return { return {
units: data.units, units: data.units,
groups: data.groups groups: data.groups,
yoloLabel: config.yoloLabel ?? 'YOLO mode',
yoloDescription: config.yoloDescription ?? '(cross-group conversions)'
}; };
}; };

View File

@@ -14,10 +14,12 @@
* Supports same-group and cross-group (YOLO) conversions. * Supports same-group and cross-group (YOLO) conversions.
*/ */
let { data }: { data: { units: Unit[]; groups: Group[] } } = $props(); let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string } } = $props();
let units: Unit[] = $derived(data.units); let units: Unit[] = $derived(data.units);
let groups: Group[] = $derived(data.groups); let groups: Group[] = $derived(data.groups);
let yoloLabel: string = $derived(data.yoloLabel);
let yoloDescription: string = $derived(data.yoloDescription);
// ── Reactive state ────────────────────────────────────────────────────────── // ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1); let inputValue = $state<number>(1);
@@ -94,6 +96,8 @@
); );
// ── URL sync ──────────────────────────────────────────────────────────────── // ── URL sync ────────────────────────────────────────────────────────────────
// Use history.replaceState directly — goto() triggers a SvelteKit navigation
// which re-renders the page and steals focus from the input on every keystroke.
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -104,7 +108,7 @@
} }
const next = params.toString(); const next = params.toString();
if (next !== window.location.search.slice(1)) { if (next !== window.location.search.slice(1)) {
goto(`?${next}`, { replaceState: true, noScroll: true }); history.replaceState(history.state, '', `?${next}`);
} }
}); });
@@ -176,7 +180,10 @@
<div class="yolo-toggle-row"> <div class="yolo-toggle-row">
<label class="yolo-label"> <label class="yolo-label">
<input type="checkbox" bind:checked={yoloMode} role="switch" /> <input type="checkbox" bind:checked={yoloMode} role="switch" />
YOLO mode <small>(cross-group conversions)</small> <span class="yolo-label-text">{yoloLabel}</span>
{#if yoloDescription}
<small class="yolo-desc">{yoloDescription}</small>
{/if}
</label> </label>
</div> </div>
@@ -198,17 +205,24 @@
<style> <style>
.yolo-toggle-row { .yolo-toggle-row {
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
.yolo-label { .yolo-label {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
white-space: nowrap;
}
.yolo-label-text {
font-weight: 500;
}
.yolo-desc {
color: var(--pico-muted-color);
white-space: nowrap;
} }
.yolo-marker { .yolo-marker {