fix(settings): space the stacked bands, and give each row action a hit area

Adopts NativeSelect for the base-currency, per-post and rule-kind selects.

Spacing. In Posts the table, the "Archiving hides a post…" footnote and the
add-post row were three siblings with no margin between them at all: the
footnote's own line-height was the only gap, so the three read as one
undifferentiated block. The footnote now has room and the add row is its own
band under a rule. Same for the add-currency row and "Fetch rates now",
which sat 8px under the paragraph explaining it.

Controls. The rule-summary trigger — the only way into a post's allocation
history — measured `h=20 pad=0 rad=0`. It gets a 24px box and the row's
hover wash. `up` / `down` / `archive` were three inline-flex siblings with
nothing between them, so their hover backgrounds touched and the cell read
as one wide slab. "Fetch rates now" was `sm` in a page where every other
section-level action is default height, leaving it 4px short of the fields
beside it.

Also: the add-currency row labelled three of its four fields by placeholder
and the fourth with a visible DECIMALS label, directly below a DECIMALS
column heading that already said it — so that label read as a heading for
the whole row. It becomes an aria-label. "Reset everything…" moves out of
the export group it was fourth in and takes a destructive border.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-02 22:58:55 +02:00
co-authored by Claude Opus 5
parent f6be0c8439
commit 25ad22a4e7
4 changed files with 75 additions and 61 deletions
+5 -4
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useMutate } from "../hooks/useMutate.ts";
import { removeRuleFrom, setRuleFrom } from "../../store/actions.ts";
import { currentMonth } from "../../store/index.ts";
@@ -47,7 +48,7 @@ export function RuleHistory({ post }: Props) {
}
return (
<div className="space-y-2 rounded border p-3">
<div className="space-y-3 rounded-md border border-budget-rule p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
@@ -99,8 +100,8 @@ export function RuleHistory({ post }: Props) {
setFrom(next);
}}
/>
<select
className="h-8 rounded border bg-background px-1 text-xs"
<NativeSelect
className="h-8 w-auto text-xs md:text-xs"
aria-label="Rule kind"
value={kind}
onChange={(event) => {
@@ -110,7 +111,7 @@ export function RuleHistory({ post }: Props) {
>
<option value="percentOfIncome">% of income</option>
<option value="fixed">fixed amount</option>
</select>
</NativeSelect>
<Input
className="font-money h-8 w-24"
type="number"
+32 -31
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Section } from "../../components/Section.tsx";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
@@ -74,9 +75,9 @@ export function CurrencySection() {
>
Base currency (all totals use this)
</Label>
<select
<NativeSelect
id="base-currency"
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
className="w-40"
value={base}
onChange={(event) => {
const currency = event.target.value;
@@ -88,7 +89,7 @@ export function CurrencySection() {
{code}
</option>
))}
</select>
</NativeSelect>
</div>
<div className="overflow-x-auto">
@@ -192,9 +193,9 @@ export function CurrencySection() {
</td>
<td className="py-2 pl-4 text-right">
<Button
size="sm"
size="xs"
variant="ghost"
className="text-overspend hover:text-overspend disabled:opacity-40"
className="-mr-2 text-overspend hover:text-overspend disabled:opacity-40"
disabled={used.length > 0}
title={
used.length > 0
@@ -217,7 +218,7 @@ export function CurrencySection() {
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
<div className="mt-6 border-t border-budget-rule pt-5">
<div className="space-y-1.5">
<Label
htmlFor="fx-url"
@@ -236,16 +237,20 @@ export function CurrencySection() {
});
}}
/>
<p className="text-xs text-budget-ink-muted">
<p className="max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
works offline with the rates you type in above, and a currency the service
does not know can always be maintained by hand.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
<Button variant="outline" className="mt-4" onClick={() => void refresh()}>
Fetch rates now
</Button>
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
{status && (
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
{status}
</p>
)}
</div>
</Section>
);
@@ -277,7 +282,7 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
}
return (
<div className="mt-4 flex flex-wrap items-end gap-2">
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
<Input
className="font-money h-9 w-24 uppercase"
placeholder="JPY"
@@ -308,27 +313,23 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
setSymbol(next);
}}
/>
<div className="space-y-1">
<Label
htmlFor="new-digits"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Decimals
</Label>
<Input
id="new-digits"
type="number"
min="0"
max="4"
step="1"
className="font-money h-9 w-16 text-right"
value={digits}
onChange={(event) => {
const next = event.target.value;
setDigits(next);
}}
/>
</div>
{/* No visible label. Its three siblings are labelled by placeholder, and
the DECIMALS column heading sits directly above this field in the
table — a fourth label here read as a heading for the whole row. */}
<Input
id="new-digits"
type="number"
min="0"
max="4"
step="1"
aria-label="Decimal places for the new currency"
className="font-money h-9 w-16 text-right"
value={digits}
onChange={(event) => {
const next = event.target.value;
setDigits(next);
}}
/>
<Button variant="outline" disabled={!canAdd} onClick={add}>
Add currency
</Button>
+3 -4
View File
@@ -122,7 +122,6 @@ export function DataSection() {
title="Your data"
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportJson}>
Export JSON
@@ -135,7 +134,7 @@ export function DataSection() {
</Button>
<Button
variant="outline"
className="text-destructive hover:text-destructive"
className="ms-auto border-destructive/40 text-destructive hover:border-destructive hover:bg-destructive/5 hover:text-destructive"
onClick={() => {
setError(null);
setPending(null);
@@ -160,7 +159,7 @@ export function DataSection() {
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
{confirmingReset && (
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
<p className="font-medium">This deletes everything in this browser.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "}
@@ -186,7 +185,7 @@ export function DataSection() {
)}
{pending && (
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
<p className="font-medium">This replaces everything currently stored.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.
+35 -22
View File
@@ -1,6 +1,7 @@
import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
@@ -39,7 +40,6 @@ export function PostsSection() {
title="Posts"
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
>
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
@@ -64,8 +64,9 @@ export function PostsSection() {
/>
</td>
<td className="py-2">
<select
className="h-8 rounded border bg-background px-1 text-xs"
<NativeSelect
className="h-8 w-auto text-xs md:text-xs"
aria-label={`Display currency for ${post.name}`}
value={post.currency}
onChange={(event) => {
const currency = event.target.value as Currency;
@@ -77,33 +78,45 @@ export function PostsSection() {
{currency}
</option>
))}
</select>
</NativeSelect>
</td>
<td className="py-2">
<button
{/* Was a bare button: 20px tall, no padding, and the only
way into a post's rule history. `size="xs"` gives it a
24px hit box and the row's own hover wash, and the
negative margin keeps the text aligned with the column
heading above it. */}
<Button
type="button"
className="underline decoration-dotted"
variant="ghost"
size="xs"
className="-mx-2 max-w-full font-normal underline decoration-dotted underline-offset-2"
aria-expanded={openHistory === post.id}
onClick={() =>
setOpenHistory((id) => (id === post.id ? null : post.id))
}
>
{ruleSummary(post)}
</button>
</Button>
</td>
<td className="py-2 text-right">
<Button size="sm" variant="ghost" disabled={index === 0}
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
up
</Button>
<Button size="sm" variant="ghost" disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
down
</Button>
<Button size="sm" variant="ghost"
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
{post.archived ? "restore" : "archive"}
</Button>
<td className="py-2">
{/* Three inline-flex siblings with nothing between them: the
hover backgrounds touched, so the row read as one wide
slab rather than three actions. */}
<div className="flex items-center justify-end gap-1">
<Button size="xs" variant="ghost" disabled={index === 0}
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
up
</Button>
<Button size="xs" variant="ghost" disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
down
</Button>
<Button size="xs" variant="ghost" className="-mr-2"
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
{post.archived ? "restore" : "archive"}
</Button>
</div>
</td>
</tr>
{openHistory === post.id && (
@@ -117,12 +130,12 @@ export function PostsSection() {
))}
</tbody>
</table>
<p className="text-xs text-budget-ink-muted">
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
Archiving hides a post from new purchases while keeping its history. Posts
are never deleted, because purchases reference them.
</p>
<div className="flex items-end gap-2">
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
<Input
className="w-48"
placeholder="New post name"