Sub-project 1 of the redesign. The diagnosis that shaped it: a design system already existed and was never wired up. src/index.css defined --paper, --ink and a muted teal with a contract document behind them, while the app painted itself with shadcn's default white and near-black. Those tokens reached the screen in exactly two places — the carry meter and the +/- number colours. TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per theme, and shadcn's whole variable set points at it, so every primitive inherits both themes rather than being fought. Light is cool paper with ink navy; dark is deep slate, never pure black, because a black ground makes the hairlines and the carry meter float. The brand teal is --primary, NOT --accent. That is precisely what the old bug got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with --accent-foreground, so a saturated colour there turns every hover into a coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule mechanical — redefining either half of a shadcn pair without the other fails the build, in any block. CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on both of their backgrounds. Verified afterwards by reading the tokens back out of the live browser in both themes rather than trusting the arithmetic. TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler resolves every url() from disk and inlines woff2 as base64 — relative or absolute, and a "file" loader override does not stop it — which made the stylesheet 303 KB of render-blocking bytes and defeated the unicode-range subsetting entirely. They are injected at boot from one definition instead, which is the only arrangement identical in dev and in a subpath build. THEME TOGGLE cycles system / light / dark, remembers the choice in localStorage (a per-device display preference does not belong in the exported budget), keeps following the OS while set to "system", and is applied by a tiny inline script before first paint so a dark-theme user never sees a white flash. Every storage access is wrapped: localStorage throws outright in some configurations, and a theme preference must never stop the app booting. A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently killed the carry meter. An undefined custom property inside linear-gradient() does not warn, does not throw and does not fall back — the declaration is invalid, so the element paints nothing. It looked like a missing feature. Only pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any TypeScript file references a token src/index.css does not define. Also: one Section/Stat card treatment applied across month, year, summary and settings; quiet uppercase table headers; row hover; the rule-editing and purchase-row actions revealed on hover instead of six always-on links; a scroll fade on the year matrix so it reads as "more to come" rather than clipped; and "September 2026" instead of "2026-09" as the month heading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
import { Fragment, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useDataset } from "../../hooks/useDataset.ts";
|
|
import { useMutate } from "../../hooks/useMutate.ts";
|
|
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
|
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
|
import { ruleAt } from "../../../domain/allocation.ts";
|
|
import { currentMonth } from "../../../store/index.ts";
|
|
import { RuleHistory } from "../../components/RuleHistory.tsx";
|
|
import { Section } from "../../components/Section.tsx";
|
|
|
|
export function PostsSection() {
|
|
const dataset = useDataset();
|
|
const { mutate } = useMutate();
|
|
const [newName, setNewName] = useState("");
|
|
const [openHistory, setOpenHistory] = useState<string | null>(null);
|
|
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
|
|
const base = dataset.settings.baseCurrency;
|
|
|
|
function ruleSummary(post: Post) {
|
|
const effective = ruleAt(post, currentMonth);
|
|
if (!effective) {
|
|
return <span className="text-xs text-muted-foreground">not budgeted</span>;
|
|
}
|
|
const { rule } = effective;
|
|
return (
|
|
<span className="text-xs">
|
|
{rule.kind === "fixed"
|
|
? `${rule.amount.amount} ${rule.amount.currency}`
|
|
: `${rule.percent}% of income`}
|
|
<span className="ml-1 text-muted-foreground">from {effective.from}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Section
|
|
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">
|
|
<th className="py-2 font-medium">Name</th>
|
|
<th className="py-2 font-medium">Display currency</th>
|
|
<th className="py-2 font-medium">Rule</th>
|
|
<th className="py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ordered.map((post, index) => (
|
|
<Fragment key={post.id}>
|
|
<tr className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
|
|
<td className="py-2">
|
|
<Input
|
|
className="h-8 w-48"
|
|
value={post.name}
|
|
onChange={(event) => {
|
|
const name = event.target.value;
|
|
mutate((draft) => updatePost(draft, post.id, { name }));
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-2">
|
|
<select
|
|
className="h-8 rounded border bg-background px-1 text-xs"
|
|
value={post.currency}
|
|
onChange={(event) => {
|
|
const currency = event.target.value as Currency;
|
|
mutate((draft) => updatePost(draft, post.id, { currency }));
|
|
}}
|
|
>
|
|
{CURRENCIES.map((currency) => (
|
|
<option key={currency} value={currency}>
|
|
{currency}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
<td className="py-2">
|
|
<button
|
|
type="button"
|
|
className="underline decoration-dotted"
|
|
aria-expanded={openHistory === post.id}
|
|
onClick={() =>
|
|
setOpenHistory((id) => (id === post.id ? null : post.id))
|
|
}
|
|
>
|
|
{ruleSummary(post)}
|
|
</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>
|
|
</tr>
|
|
{openHistory === post.id && (
|
|
<tr className="border-b last:border-0">
|
|
<td colSpan={4} className="pb-3">
|
|
<RuleHistory post={post} />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<p className="text-xs 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">
|
|
<Input
|
|
className="w-48"
|
|
placeholder="New post name"
|
|
value={newName}
|
|
onChange={(event) => setNewName(event.target.value)}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
disabled={newName.trim() === ""}
|
|
onClick={() => {
|
|
mutate((draft) => addPost(draft, newName.trim(), base));
|
|
setNewName("");
|
|
}}
|
|
>
|
|
Add post
|
|
</Button>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|