From af83263520d7a5e5136ad5e2969e0374cc3a61e4 Mon Sep 17 00:00:00 2001 From: Falkan Date: Tue, 17 Mar 2026 20:13:57 -0400 Subject: [PATCH] feat: two-line cards, responsive grid, real uniform font size --- BACKLOG.md | 12 -- src/app.css | 33 ++++- src/components/ConversionResult.svelte | 18 ++- src/components/ConverterCard.svelte | 6 +- src/lib/actions/uniformFontSize.ts | 181 +++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 src/lib/actions/uniformFontSize.ts diff --git a/BACKLOG.md b/BACKLOG.md index 9815ce9..70c9e2c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -15,13 +15,6 @@ In dark mode the "Clear" button text lightens on hover, giving visual feedback. mode and Dan mode it currently does nothing visible. Should have a matching subtle highlight (e.g. text darkens, underline appears, or border darkens). -### Admin: user-provided IDs for units and groups -When creating a unit or group, allow the user to optionally supply their own ID. -- If the ID field is left empty, auto-generate it by kebab-casing the label (live - preview as the user types the label). -- If the user types into the ID field, stop auto-generating and use what they typed. -- Validate: IDs must be unique and slug-safe. - ### Admin: allow renaming unit and group IDs The edit forms do not currently expose the `id` field for existing units or groups. @@ -46,11 +39,6 @@ Per-group setting controlling whether units are displayed on the front end in: Data shape change: add optional `sortOrder?: 'defined' | 'alpha'` to the `Group` type (default: `'defined'`). -### Admin: drag-to-reorder units within a group -Allow units to be reordered within their group via drag-and-drop in the left panel. -Order is persisted to `units.json`. Only meaningful when the group's `sortOrder` is -`'defined'`. - ### Admin: unsaved-form guard on "Add Unit" / "Add Group" If the user is in the middle of filling out a create/edit form and clicks "+ Add Unit" or "+ Add Group", the current form should be auto-saved before opening the new one. diff --git a/src/app.css b/src/app.css index b7da988..7086426 100644 --- a/src/app.css +++ b/src/app.css @@ -2,8 +2,8 @@ /* CSS custom properties (set dynamically via inline style from ConverterCard) */ :root { - --min-col-width: 160px; --grid-gap: 1rem; + --card-font-size: 1rem; /* overridden per-grid by uniformFontSize action */ /* Shadow for card elevation (theme-neutral) */ --shadow-color: 30deg 8% 20%; @@ -238,13 +238,28 @@ } /* ── Results grid ──────────────────────────────────────────────────────────── */ +/* Responsive column count: 1 col mobile, 2 col tablet, 3 col desktop. */ +/* Cards grow to fill available space (1fr). Column count is a minimum — */ +/* more columns are fine if content allows. */ .results-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(var(--min-col-width), 1fr)); + grid-template-columns: 1fr; gap: var(--grid-gap); margin-top: 1.5rem; } +@media (min-width: 480px) { + .results-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 900px) { + .results-grid { + grid-template-columns: repeat(3, 1fr); + } +} + /* ── Result tile ───────────────────────────────────────────────────────────── */ .result-tile { cursor: pointer; @@ -256,6 +271,9 @@ display: flex; flex-direction: column; gap: 0.2rem; + /* Fixed two-line height so all cards are the same size */ + min-height: 4.5rem; + justify-content: center; transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; user-select: none; /* Pico's [role=button] rule sets --pico-color: var(--pico-primary-inverse) @@ -272,16 +290,21 @@ } .result-value { - font-size: 1rem; + font-size: var(--card-font-size, 1rem); font-weight: 600; font-variant-numeric: tabular-nums; - word-break: break-all; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; /* color inherits from .result-tile which has already corrected for Pico's button reset */ } .result-label { - font-size: 0.85rem; + font-size: var(--card-font-size, 0.85rem); color: var(--color-label); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* .result-symbol removed — symbol is now rendered inline in the label */ diff --git a/src/components/ConversionResult.svelte b/src/components/ConversionResult.svelte index 43ba4b8..c80e7c6 100644 --- a/src/components/ConversionResult.svelte +++ b/src/components/ConversionResult.svelte @@ -6,6 +6,10 @@ /** * ConversionResult — displays one conversion result tile. + * Always two-line layout: + * Line 1: converted value (large, prominent) + * Line 2: unit label + symbol (smaller/muted) + * * Emits onclick (highlight), onshiftclick (make input unit), * and onctrlclick (adopt value) events. * Handles nullable value for cross-group N/A state. @@ -59,13 +63,15 @@ aria-label={value !== null ? `${formattedValue} ${unit.labelPlural}` : `N/A ${unit.labelPlural}`} aria-pressed={highlighted} > - {#if value === null} - N/A - {:else} - + + + {#if value !== null} {#if isYolo}~{/if}{formattedValue} - - {/if} + {:else} + N/A + {/if} + + {unit.labelPlural} ({unit.symbol}) diff --git a/src/components/ConverterCard.svelte b/src/components/ConverterCard.svelte index d34f2ac..2c4fdc2 100644 --- a/src/components/ConverterCard.svelte +++ b/src/components/ConverterCard.svelte @@ -1,7 +1,7 @@
@@ -47,7 +45,7 @@ {#if orderedResults.length > 1 && section.groupLabel}
{section.groupLabel}
{/if} -
+
{#each section.items as item (item.unit.id)} + * + * How it works: + * 1. After each render (via tick()), for every .result-tile in the container: + * - Measure available inner width (clientWidth − horizontal padding) + * - Measure rendered text width of .result-value and .result-label using + * an off-screen span with matching font properties + * - Compute scale = availableWidth / max(valueWidth, labelWidth) + * 2. Take minimum scale across all tiles (tightest tile wins) + * 3. Clamp: min 0.6rem (floor), max = base font size (never inflate) + * 4. Set --card-font-size on the container + * 5. For any element still overflowing at the floor, add ellipsis + title + * + * ResizeObserver on the container re-runs measurement, debounced 50 ms. + */ + +import { tick } from 'svelte'; +import { browser } from '$app/environment'; + +export interface UniformFontSizeParams { + /** Pass the current results array; any change triggers re-measurement. */ + results: unknown; +} + +// Base font size we scale from (1rem). Expressed in px at measure time. +const BASE_REM = 1; // rem +const FLOOR_REM = 0.6; // rem +const DEBOUNCE_MS = 50; + +/** Return the computed horizontal padding (left + right) of an element in px. */ +function horizontalPadding(el: HTMLElement): number { + const style = window.getComputedStyle(el); + return parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); +} + +/** Measure the rendered pixel width of `text` at `fontSize`px using a hidden span. */ +function measureText( + text: string, + fontSize: number, + fontWeight: string, + fontFamily: string, + span: HTMLSpanElement +): number { + span.style.fontSize = `${fontSize}px`; + span.style.fontWeight = fontWeight; + span.style.fontFamily = fontFamily; + span.textContent = text; + return span.getBoundingClientRect().width; +} + +/** Get root font size in px (for rem→px conversion). */ +function getRootFontSizePx(): number { + return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; +} + +export function uniformFontSize( + node: HTMLElement, + params: UniformFontSizeParams +): { update: (p: UniformFontSizeParams) => void; destroy: () => void } { + if (!browser) { + return { update: () => {}, destroy: () => {} }; + } + + // Off-screen measurement span — created once, reused, destroyed on action teardown. + const measureSpan = document.createElement('span'); + measureSpan.style.cssText = + 'position:fixed;top:-9999px;left:-9999px;visibility:hidden;white-space:nowrap;pointer-events:none;'; + document.body.appendChild(measureSpan); + + let debounceTimer: ReturnType | null = null; + let observer: ResizeObserver | null = null; + + async function measure() { + // Wait for Svelte to flush DOM updates. + await tick(); + + const tiles = Array.from(node.querySelectorAll('.result-tile')); + if (tiles.length === 0) return; + + const rootPx = getRootFontSizePx(); + const basePx = BASE_REM * rootPx; + const floorPx = FLOOR_REM * rootPx; + + let minScale = 1; // never inflate above base + + for (const tile of tiles) { + const availableWidth = tile.clientWidth - horizontalPadding(tile); + if (availableWidth <= 0) continue; + + const valueEl = tile.querySelector('.result-value'); + const labelEl = tile.querySelector('.result-label'); + if (!valueEl || !labelEl) continue; + + // Get font properties from the live elements (inherits CSS vars etc.) + const valueStyle = window.getComputedStyle(valueEl); + const labelStyle = window.getComputedStyle(labelEl); + + const valueText = valueEl.textContent ?? ''; + const labelText = labelEl.textContent ?? ''; + + const valueWidth = measureText( + valueText, + basePx, + valueStyle.fontWeight, + valueStyle.fontFamily, + measureSpan + ); + const labelWidth = measureText( + labelText, + basePx, + labelStyle.fontWeight, + labelStyle.fontFamily, + measureSpan + ); + + const maxTextWidth = Math.max(valueWidth, labelWidth); + if (maxTextWidth <= 0) continue; + + const scale = availableWidth / maxTextWidth; + if (scale < minScale) minScale = scale; + } + + // Compute final font size in rem, clamped to floor. + const computedPx = Math.max(floorPx, basePx * minScale); + const computedRem = computedPx / rootPx; + + node.style.setProperty('--card-font-size', `${computedRem.toFixed(4)}rem`); + + // Apply truncation on elements that still overflow at the floor. + if (computedPx <= floorPx + 0.01) { + for (const tile of tiles) { + for (const selector of ['.result-value', '.result-label']) { + const el = tile.querySelector(selector); + if (!el) continue; + if (el.scrollWidth > el.clientWidth) { + el.style.overflow = 'hidden'; + el.style.whiteSpace = 'nowrap'; + el.style.textOverflow = 'ellipsis'; + el.title = el.textContent ?? ''; + } + } + } + } + } + + function scheduleMeasure() { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + measure(); + }, DEBOUNCE_MS); + } + + // Initial measurement (after first render). + measure(); + + // ResizeObserver — single observer on the grid container. + observer = new ResizeObserver(scheduleMeasure); + observer.observe(node); + + return { + update(_newParams: UniformFontSizeParams) { + // Called by Svelte whenever the `results` prop changes — re-measure. + measure(); + }, + destroy() { + observer?.disconnect(); + observer = null; + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + measureSpan.remove(); + } + }; +}