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">
/**
* UnitInput — uncontrolled numeric input with one-way push to parent.
*
* 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.
* UnitInput — controlled numeric input.
*/
let {
@@ -21,18 +11,6 @@
onchange: (v: number) => void;
} = $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) {
const input = e.currentTarget as HTMLInputElement;
const parsed = parseFloat(input.value);
@@ -43,9 +21,9 @@
</script>
<input
bind:this={inputEl}
type="number"
step="any"
value={value}
oninput={handleInput}
aria-label="Input value"
/>

View File

@@ -11,6 +11,10 @@ export interface AppConfig {
adminPath: string;
passwordHash: 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. */

View File

@@ -1,10 +1,13 @@
import { loadData } from '$lib/server/data';
import { loadData, loadConfig } from '$lib/server/data';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
const data = loadData();
const config = loadConfig();
return {
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.
*/
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 groups: Group[] = $derived(data.groups);
let yoloLabel: string = $derived(data.yoloLabel);
let yoloDescription: string = $derived(data.yoloDescription);
// ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1);
@@ -94,6 +96,8 @@
);
// ── 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(() => {
if (!browser) return;
const params = new URLSearchParams();
@@ -104,7 +108,7 @@
}
const next = params.toString();
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">
<label class="yolo-label">
<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>
</div>
@@ -198,17 +205,24 @@
<style>
.yolo-toggle-row {
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.yolo-label {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.yolo-label-text {
font-weight: 500;
}
.yolo-desc {
color: var(--pico-muted-color);
white-space: nowrap;
}
.yolo-marker {