Phase 2: core converter, live conversion, URL state, highlight interaction

This commit is contained in:
Falkan
2026-03-16 21:34:16 -04:00
parent 65f8bc1b9f
commit 8f835c35d2
9 changed files with 430 additions and 1 deletions

115
src/app.css Normal file
View File

@@ -0,0 +1,115 @@
/* app.css — global styles for humor-units */
/* CSS custom properties (set dynamically via inline style from ConverterCard) */
:root {
--min-col-width: 160px;
--grid-gap: 1rem;
}
/* ── Results grid ──────────────────────────────────────────────────────────── */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--min-col-width), 1fr));
gap: var(--grid-gap);
margin-top: 1.5rem;
}
/* ── Result tile ───────────────────────────────────────────────────────────── */
.result-tile {
cursor: pointer;
padding: 0.75rem 1rem;
border: 1px solid var(--pico-muted-border-color, #ccc);
border-radius: var(--pico-border-radius, 0.25rem);
display: flex;
flex-direction: column;
gap: 0.2rem;
transition: background-color 0.15s ease, border-color 0.15s ease;
user-select: none;
}
.result-tile:hover {
background-color: var(--pico-secondary-background, rgba(0, 0, 0, 0.04));
border-color: var(--pico-primary, #1095c1);
}
.result-tile.highlighted {
border-color: var(--pico-primary, #1095c1);
background-color: var(--pico-primary-background, rgba(16, 149, 193, 0.08));
}
.result-value {
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.result-label {
font-size: 0.85rem;
color: var(--pico-muted-color, #666);
}
.result-symbol {
font-size: 0.75rem;
color: var(--pico-muted-color, #666);
font-style: italic;
}
/* ── Highlighted result (above grid) ──────────────────────────────────────── */
.highlighted-result {
text-align: center;
padding: 2rem 1rem;
margin-bottom: 1.5rem;
border-radius: var(--pico-border-radius, 0.25rem);
background-color: var(--pico-primary-background, rgba(16, 149, 193, 0.08));
border: 2px solid var(--pico-primary, #1095c1);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.highlighted-value {
font-size: 2.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
word-break: break-all;
}
.highlighted-label {
font-size: 1.25rem;
color: var(--pico-color, inherit);
}
.highlighted-symbol {
font-size: 0.9rem;
color: var(--pico-muted-color, #666);
font-style: italic;
}
/* ── Converter controls ────────────────────────────────────────────────────── */
.converter-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.converter-controls input[type='number'] {
flex: 1 1 200px;
min-width: 120px;
}
.converter-controls select {
flex: 0 1 auto;
min-width: 160px;
}
/* ── Hotkey hint ───────────────────────────────────────────────────────────── */
.hotkey-hint {
margin-top: 1rem;
margin-bottom: 0;
color: var(--pico-muted-color, #666);
text-align: center;
}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { Big } from 'big.js';
import type { Unit } from '$lib/types';
import { formatBig } from '$lib/format';
/**
* ConversionResult — displays one conversion result tile.
* Emits onclick (highlight) and onshiftclick (make input unit) events.
*/
let {
value,
unit,
highlighted,
onclick,
onshiftclick
}: {
value: Big;
unit: Unit;
highlighted: boolean;
onclick: () => void;
onshiftclick: () => void;
} = $props();
// Compute once per reactive update; shared between aria-label and display span.
let formattedValue = $derived(formatBig(value));
function handleClick(e: MouseEvent) {
if (e.shiftKey) {
onshiftclick();
} else {
onclick();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="result-tile"
class:highlighted
onclick={handleClick}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') onclick(); }}
aria-label="{formattedValue} {unit.labelPlural}"
aria-pressed={highlighted}
>
<span class="result-value">{formattedValue}</span>
<span class="result-label">{unit.labelPlural}</span>
<span class="result-symbol">{unit.symbol}</span>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Unit, ResultItem } from '$lib/types';
import { MIN_COLUMN_WIDTH, GRID_GAP } from '$lib/config';
import UnitInput from './UnitInput.svelte';
import UnitDropdown from './UnitDropdown.svelte';
import ConversionResult from './ConversionResult.svelte';
/**
* ConverterCard — groups UnitInput + UnitDropdown + results grid + hotkey hint.
* Emits events upward; does NOT contain NL input (Phase 3).
*/
let {
units,
inputValue,
fromUnitId,
highlightedUnitId,
results,
oninputchange,
onunitchange,
onhighlight,
onsetinput
}: {
units: Unit[];
inputValue: number;
fromUnitId: string;
highlightedUnitId: string | null;
results: ResultItem[];
oninputchange: (v: number) => void;
onunitchange: (id: string) => void;
onhighlight: (id: string) => void;
onsetinput: (id: string) => void;
} = $props();
const gridStyle = `--min-col-width: ${MIN_COLUMN_WIDTH}; --grid-gap: ${GRID_GAP};`;
</script>
<article>
<div class="converter-controls">
<UnitInput value={inputValue} onchange={oninputchange} />
<UnitDropdown {units} value={fromUnitId} onchange={onunitchange} />
</div>
<div class="results-grid" style={gridStyle}>
{#each results as item (item.unit.id)}
<ConversionResult
value={item.convertedValue}
unit={item.unit}
highlighted={item.unit.id === highlightedUnitId}
onclick={() => onhighlight(item.unit.id)}
onshiftclick={() => onsetinput(item.unit.id)}
/>
{/each}
</div>
<p class="hotkey-hint">
<small>Click a result to highlight it · Shift+click to use it as input</small>
</p>
</article>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Unit } from '$lib/types';
/**
* UnitDropdown — select element populated from a Unit[] prop.
* Emits onchange with the selected unit ID string.
*/
let {
units,
value,
onchange
}: {
units: Unit[];
value: string;
onchange: (id: string) => void;
} = $props();
function handleChange(e: Event) {
const select = e.currentTarget as HTMLSelectElement;
onchange(select.value);
}
</script>
<select value={value} onchange={handleChange} aria-label="From unit">
{#each units as unit (unit.id)}
<option value={unit.id}>{unit.label} ({unit.symbol})</option>
{/each}
</select>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
/**
* UnitInput — controlled numeric input.
* Phase 2: number input only. NL field added in Phase 3.
*/
let {
value,
onchange
}: {
value: number;
onchange: (v: number) => void;
} = $props();
function handleInput(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const parsed = parseFloat(input.value);
if (!isNaN(parsed)) {
onchange(parsed);
}
}
</script>
<input
type="number"
step="any"
value={value}
oninput={handleInput}
aria-label="Input value"
/>

16
src/lib/format.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Big } from 'big.js';
import { FRACTIONAL_DIGITS, INTEGER_DIGITS } from './config';
/**
* Format a Big value for display.
* Uses FRACTIONAL_DIGITS decimal places (fixed-point).
* If INTEGER_DIGITS is set, zero-pads the integer portion to that width.
*/
export function formatBig(v: Big): string {
const fixed = v.toFixed(FRACTIONAL_DIGITS);
if (INTEGER_DIGITS === undefined) return fixed;
const [intPart, fracPart] = fixed.split('.');
const paddedInt = intPart.padStart(INTEGER_DIGITS, '0');
return fracPart !== undefined ? `${paddedInt}.${fracPart}` : paddedInt;
}

View File

@@ -32,3 +32,9 @@ export interface UnitGroup {
label: string;
units: Unit[];
}
/** One computed conversion result — unit plus its converted Big value. */
export interface ResultItem {
unit: Unit;
convertedValue: import('big.js').Big;
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import '@picocss/pico/css/pico.min.css';
import '../app.css';
let { children } = $props();
</script>

View File

@@ -1 +1,122 @@
<h1>Humor Units</h1>
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { florpUnits } from '$lib/units/definitions';
import { convertById } from '$lib/convert';
import { FRACTIONAL_DIGITS } from '$lib/config';
import { formatBig } from '$lib/format';
import type { Unit, ResultItem } from '$lib/types';
import ConverterCard from '../components/ConverterCard.svelte';
/**
* Converter page — owns all reactive state.
* Phase 2: live conversion, URL state, highlight interaction.
* Phase 3 will add NL input.
*/
const units: Unit[] = florpUnits;
// ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1);
let fromUnitId = $state<string>(units[0].id);
let highlightedUnitId = $state<string | null>(null);
// ── Initialize from URL params on first client-side load ───────────────────
if (browser) {
const params = page.url.searchParams;
const pValue = params.get('value');
const pFrom = params.get('from');
const pHighlight = params.get('highlight');
if (pValue !== null) {
const n = parseFloat(pValue);
if (!isNaN(n)) inputValue = n;
}
if (pFrom !== null && units.some((u) => u.id === pFrom)) {
fromUnitId = pFrom;
}
if (pHighlight !== null && units.some((u) => u.id === pHighlight)) {
highlightedUnitId = pHighlight;
}
}
// ── Derived: results for all units except fromUnit ─────────────────────────
let results = $derived<ResultItem[]>(
units
.filter((u) => u.id !== fromUnitId)
.map((u) => ({
unit: u,
convertedValue: convertById(inputValue, fromUnitId, u.id, units)
}))
);
// ── Derived: highlighted result (unit + value) sourced from results array ──
// The highlighted unit is always a non-from unit, so it's always in results.
let highlightedResult = $derived(
highlightedUnitId !== null
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
: null
);
// ── URL sync: update URL only when serialized params actually change ────────
$effect(() => {
if (!browser) return;
const params = new URLSearchParams();
params.set('value', String(inputValue));
params.set('from', fromUnitId);
if (highlightedUnitId !== null) {
params.set('highlight', highlightedUnitId);
}
const next = params.toString();
if (next !== window.location.search.slice(1)) {
goto(`?${next}`, { replaceState: true, noScroll: true });
}
});
// ── Event handlers ──────────────────────────────────────────────────────────
function handleUnitChange(id: string) {
fromUnitId = id;
// Clear highlight if the selected unit becomes the new from-unit.
if (highlightedUnitId === id) {
highlightedUnitId = null;
}
}
function handleHighlight(id: string) {
highlightedUnitId = id === highlightedUnitId ? null : id;
}
function handleSetInput(id: string) {
// Shift+click: promote result unit to from-unit.
// Re-express the current value in terms of the new from-unit.
const convertedValue = convertById(inputValue, fromUnitId, id, units);
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
fromUnitId = id;
highlightedUnitId = null;
}
</script>
<svelte:head>
<title>Humor Units — Converter</title>
</svelte:head>
{#if highlightedResult !== null}
<div class="highlighted-result">
<span class="highlighted-value">{formatBig(highlightedResult.convertedValue)}</span>
<span class="highlighted-label">{highlightedResult.unit.labelPlural}</span>
<span class="highlighted-symbol">{highlightedResult.unit.symbol}</span>
</div>
{/if}
<ConverterCard
{units}
{inputValue}
{fromUnitId}
{highlightedUnitId}
{results}
oninputchange={(v) => (inputValue = v)}
onunitchange={handleUnitChange}
onhighlight={handleHighlight}
onsetinput={handleSetInput}
/>