feat: two-line cards, responsive grid, real uniform font size
This commit is contained in:
12
BACKLOG.md
12
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
|
mode and Dan mode it currently does nothing visible. Should have a matching subtle
|
||||||
highlight (e.g. text darkens, underline appears, or border darkens).
|
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
|
### Admin: allow renaming unit and group IDs
|
||||||
The edit forms do not currently expose the `id` field for existing units or groups.
|
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
|
Data shape change: add optional `sortOrder?: 'defined' | 'alpha'` to the `Group` type
|
||||||
(default: `'defined'`).
|
(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"
|
### 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"
|
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.
|
or "+ Add Group", the current form should be auto-saved before opening the new one.
|
||||||
|
|||||||
33
src/app.css
33
src/app.css
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
/* CSS custom properties (set dynamically via inline style from ConverterCard) */
|
/* CSS custom properties (set dynamically via inline style from ConverterCard) */
|
||||||
:root {
|
:root {
|
||||||
--min-col-width: 160px;
|
|
||||||
--grid-gap: 1rem;
|
--grid-gap: 1rem;
|
||||||
|
--card-font-size: 1rem; /* overridden per-grid by uniformFontSize action */
|
||||||
|
|
||||||
/* Shadow for card elevation (theme-neutral) */
|
/* Shadow for card elevation (theme-neutral) */
|
||||||
--shadow-color: 30deg 8% 20%;
|
--shadow-color: 30deg 8% 20%;
|
||||||
@@ -238,13 +238,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Results grid ──────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.results-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(var(--min-col-width), 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: var(--grid-gap);
|
gap: var(--grid-gap);
|
||||||
margin-top: 1.5rem;
|
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 ───────────────────────────────────────────────────────────── */
|
||||||
.result-tile {
|
.result-tile {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -256,6 +271,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
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;
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
/* Pico's [role=button] rule sets --pico-color: var(--pico-primary-inverse)
|
/* Pico's [role=button] rule sets --pico-color: var(--pico-primary-inverse)
|
||||||
@@ -272,16 +290,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-value {
|
.result-value {
|
||||||
font-size: 1rem;
|
font-size: var(--card-font-size, 1rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
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 */
|
/* color inherits from .result-tile which has already corrected for Pico's button reset */
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-label {
|
.result-label {
|
||||||
font-size: 0.85rem;
|
font-size: var(--card-font-size, 0.85rem);
|
||||||
color: var(--color-label);
|
color: var(--color-label);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .result-symbol removed — symbol is now rendered inline in the label */
|
/* .result-symbol removed — symbol is now rendered inline in the label */
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ConversionResult — displays one conversion result tile.
|
* 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),
|
* Emits onclick (highlight), onshiftclick (make input unit),
|
||||||
* and onctrlclick (adopt value) events.
|
* and onctrlclick (adopt value) events.
|
||||||
* Handles nullable value for cross-group N/A state.
|
* 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-label={value !== null ? `${formattedValue} ${unit.labelPlural}` : `N/A ${unit.labelPlural}`}
|
||||||
aria-pressed={highlighted}
|
aria-pressed={highlighted}
|
||||||
>
|
>
|
||||||
{#if value === null}
|
<!-- Line 1: the converted value -->
|
||||||
<span class="result-value result-na">N/A</span>
|
<span class="result-value" class:result-na={value === null}>
|
||||||
{:else}
|
{#if value !== null}
|
||||||
<span class="result-value">
|
|
||||||
{#if isYolo}<span class="yolo-tilde">~</span>{/if}{formattedValue}
|
{#if isYolo}<span class="yolo-tilde">~</span>{/if}{formattedValue}
|
||||||
</span>
|
{:else}
|
||||||
|
N/A
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
|
<!-- Line 2: label + symbol -->
|
||||||
<span class="result-label" use:tooltip={unit.description ?? ''}>{unit.labelPlural} ({unit.symbol})</span>
|
<span class="result-label" use:tooltip={unit.description ?? ''}>{unit.labelPlural} ({unit.symbol})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Unit, Group, ResultItem } from '$lib/types';
|
import type { Unit, Group, ResultItem } from '$lib/types';
|
||||||
import type { Big } from 'big.js';
|
import type { Big } from 'big.js';
|
||||||
import { MIN_COLUMN_WIDTH, GRID_GAP } from '$lib/config';
|
import { uniformFontSize } from '$lib/actions/uniformFontSize';
|
||||||
import UnitInput from './UnitInput.svelte';
|
import UnitInput from './UnitInput.svelte';
|
||||||
import UnitDropdown from './UnitDropdown.svelte';
|
import UnitDropdown from './UnitDropdown.svelte';
|
||||||
import ConversionResult from './ConversionResult.svelte';
|
import ConversionResult from './ConversionResult.svelte';
|
||||||
@@ -33,8 +33,6 @@
|
|||||||
onsetinput: (id: string) => void;
|
onsetinput: (id: string) => void;
|
||||||
onctrlclick: (id: string, value: Big) => void;
|
onctrlclick: (id: string, value: Big) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const gridStyle = `--min-col-width: ${MIN_COLUMN_WIDTH}; --grid-gap: ${GRID_GAP};`;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
@@ -47,7 +45,7 @@
|
|||||||
{#if orderedResults.length > 1 && section.groupLabel}
|
{#if orderedResults.length > 1 && section.groupLabel}
|
||||||
<div class="group-divider">{section.groupLabel}</div>
|
<div class="group-divider">{section.groupLabel}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="results-grid" style={gridStyle}>
|
<div class="results-grid" use:uniformFontSize={{ results: section.items }}>
|
||||||
{#each section.items as item (item.unit.id)}
|
{#each section.items as item (item.unit.id)}
|
||||||
<ConversionResult
|
<ConversionResult
|
||||||
value={item.convertedValue}
|
value={item.convertedValue}
|
||||||
|
|||||||
181
src/lib/actions/uniformFontSize.ts
Normal file
181
src/lib/actions/uniformFontSize.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* uniformFontSize — Svelte action that computes and applies a single font size
|
||||||
|
* to all cards in a results grid so the widest content still fits.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <div class="results-grid" use:uniformFontSize={{ results }}>
|
||||||
|
*
|
||||||
|
* 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<typeof setTimeout> | 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<HTMLElement>('.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<HTMLElement>('.result-value');
|
||||||
|
const labelEl = tile.querySelector<HTMLElement>('.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<HTMLElement>(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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user