How to build a finance dashboard where every number agrees: one seeded book, live prices
How Meridian, a 22-screen Next.js and shadcn/ui finance dashboard, derives every KPI, chart and table from one seeded book, ticks prices in a bounded walk, and 24 traps fixed on the way.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork·44 min read··Updated
From TypeScript 7 outside typescript-eslint's range and scripted shadcn adds to a release ZIP with no files and CRLFs git wouldn't convert: 17 traps from building Meridian, each with its fix.
An enterprise finance dashboard for Next.js and shadcn/ui: cash flow, net worth, transactions, budgets, accounts and cards, five investment pages with live prices, insurance, loans with a prepayment simulator and a full invoicing suite. 22 screens in light and dark.
Meridian is an enterprise finance dashboard template for Next.js and shadcn/ui: cash flow, net worth, transactions, budgets, accounts and cards, five kinds of investments, insurance and loans, and a full invoicing suite, in 22 screens with light and dark themes and a theme customizer. Every name, company and figure is invented, and all of it is computed from one seeded book, so the KPIs, the charts and the tables agree, while the investment pages tick live prices. It ships as a release ZIP (1.0.0, MIT), and you can try it at meridian.upfork.dev.
What you'll learn here, with the real code: how to generate a mock book that stays consistent and derive every figure from it; how to put TanStack Query in front of it so a real API can replace it later; how to tick live prices cheaply inside a bounded walk; how to format, prefetch and load a dashboard whose every route is dynamic; how to measure a palette's contrast instead of eyeballing it; and how to test all of it. Meridian was built from 28 to 30 October 2026 in nine orchestrated phases, each by its own Claude session, with a build log and a lessons file kept as it went. Each lesson sits where it happened; the ones from setting up the toolchain and shipping the release are in a companion article.
Meridian's Finance overview in light: insight cards, the KPI strip, revenue by stream and the report summary
The stack, and what October 2026's versions needed
The brief asked for the latest stable version of everything. What Meridian runs on:
Package
Version
next / react
16.4.3 / 19.3.1
typescript
~6.0.3 (7.0 is outside typescript-eslint's range)
tailwindcss
4.3.3
shadcn (CLI) / radix-ui
4.23.0, style new-york / 1.8.1
recharts
3.10.1
@tanstack/react-table / @tanstack/react-query
8.21.3 / 5.105.0
nuqs
2.10.2
react-hook-form / zod
7.91.0 / 4.6.7
cmdk / vaul / sonner
1.1.1 / 1.1.2 / 2.0.8
eslint / eslint-config-next
10.13.0 / 16.4.3
@playwright/test / @axe-core/playwright
1.64.1 / 4.14.0
pnpm / Node
12.8.1 / 22.23.1
Two choices from setting that up matter below: shadcn primitives that Meridian edited carry a comment starting Meridian:, and the chart, form, table and checkbox primitives were written from the registry's JSON, because chart.json pins recharts@3.8.0 and the project has 3.10.1.
One seeded book
Everything on screen comes from one in-memory book per entity: Holdings, Europe and Capital. Holdings carries the figures the design brief set; Europe (in euros, at 0.42 of Holdings' amounts) and Capital (in dollars, at 0.64) are smaller books with their own accounts, institutions, record numbers and noise. Each entity reports in its own base currency, with no FX conversion anywhere. The book is built from a fixed seed, with every date relative to today, so the demo never goes stale and looks the same on every machine and in every screenshot.
Seeded randomness, and money in cents
From src/lib/data/seed.ts: mulberry32 for the numbers, and FNV-1a over the parts of a name, so each purpose gets its own stream (createRng("ledger", "holdings", 3)). The builders get ctx.rng(...parts), which prefixes the entity.
Every total is a sum of parts, so sums are done in integer cents (sumMoney), and splitMoney hands the rounding remainder to the largest part, so the parts always add back to the total:
The ledger is generated from the other records
The ledger is 25 months of every movement, and it is built from the other records rather than beside them: bills, payroll and its funding transfer, loan instalments, insurance premiums, subscriptions, invoice payments, dividends, goal contributions, card statements and surplus sweeps, plus seeded day-to-day activity. So a loan's instalments are in the ledger because the loan exists, and the invoice list's payments are the ledger's client receipts.
Then one balancing row per month (a consulting payment and a wholesale order) steers income and expenses to that month's target. For the current month the target is pro-rated by the day, fraction = min(1, day / 28). From src/lib/data/build/ledger.ts:
The floor is there because of a test run on a February day: early in a month the fixed bills alone pass the pro-rated target, and the "wholesale order" turned into its opposite, money coming in. The unit tests now assert that income rows are inflows and expense rows outflows on three dates for every entity.
The ledger is the one costly collection (2,151 rows for Holdings when first measured, built in 234 ms in Node on a quiet machine), so src/lib/data/workspace.ts builds it on first read. getEntityData caches one book per entity per day, keyed entity|day, and mutations change that cached book in place.
Every figure is a selector
No KPI is typed in. src/lib/data/selectors/* derive each one from the records: cash totals, flows by week, month and year, budget pace, loan schedules, positions, net worth, invoice balances. An account's balance history is computed backwards from today's balance through the ledger, so the chart always ends on the balance shown beside it. From src/lib/data/selectors/spending.ts:
Net worth is assets minus liabilities, each part taken from its own page's records: the accounts, the four investment books, the loans, the credit cards and anything added by hand.
Where the brief's own figures contradicted each other, the more specific page won and the summaries followed. On 28 October Holdings shows exactly the brief's income of $34,600 and expenses of $26,180 for the month, and invoice #5099 is $3,120 + Tax (15%) $468 = $3,588. But net worth is $857,213, not the brief's $787,079 (which left a frozen reserve account out of the assets); loans outstanding are $326,010, amortised from the brief's own instalments, rates and terms; and runway is 7.9 months, liquid cash over the average monthly expenses of the last six months. One ledger can't satisfy figures that contradict each other, so the specific pages keep theirs and the roll-ups are whatever the records add up to.
Mutations move the balances
src/lib/data/store.ts exports dataActions, every change the UI can make: add a transaction, freeze a card, pay an EMI, record an invoice payment, buy or sell a holding. Each returns an undo for the toast, and every payment is booked through addTransaction, which adds the row, re-sorts the ledger and moves the balances, so cash flow, the accounts, the transactions table and net worth move together. A premium carries its policyId, an EMI its loanId, a client payment its invoiceId and a trade its holdingId.
Pinned by the unit tests
tests/unit/data.spec.ts pins two things: the brief's figures for Holdings on the reference day, and invariants for all three entities on three days (the reference day, an early-month day and a February day): the book is deterministic, totals equal the sum of their parts, every balance history ends on its balance, shares add to 100 ± 0.1, upcoming lists are sorted and in the future, invoice lines are hours × rate. The start of it:
The data layer: TanStack Query over the book
The pages never read the book directly. They use hooks (useTransactions(), useLoansPage() and so on) built on one function, useEntityQuery, which answers from the book after a simulated 250–600 ms, so the loading skeletons are real. The key is ["workspace", entity, domain, ...params]: every entity and filter caches apart, and any mutation invalidates them all. From src/lib/queries/client.tsx:
placeholderData keeps the old figures on screen while a new period loads, so charts change without going back to a skeleton; a new entity is in another currency, so it shows its skeletons instead. Each hook is a selector passed to that function: useTransactions(filters) filters the ledger by the period and the filters and returns it newest first.
To connect a real API, replace fetchFromBook with a fetch to your backend and point each dataActions call at a mutation: the hooks, the query keys and the pages stay as they are.
The period and the entity live in the URL
useWorkspace() (src/hooks/use-workspace.ts) reads the global period, the custom range, the entity and the currency from the query string through nuqs, so a link or a reload keeps them, and defaults stay out of the URL. Every comparison uses the same days of the period before: a month so far is compared with the same days of the month before, so on 28 October expenses read −7.4% "vs Sep 1–28" (against the whole of September they would read −8.4%, and look like a collapse early in any month). From src/lib/data/dates.ts:
One DataTable
Every table in Meridian is one component on TanStack Table v8 (src/components/data-table/data-table.tsx): saved views with counts, debounced search, faceted filters, multi-column sorting with aria-sort, column visibility, density, row selection with an indeterminate header and a floating bulk bar, a sticky header and first column, clickable rows, phone cards, and CSV export of the filtered rows, from its toolbar or from the command palette's "Export current table" event (the first table that hears it answers):
Live prices: a bounded random walk
Equities, F&O and Crypto tick; mutual funds and bonds don't, because a fund prices once a day at its NAV and a bond is marked once a day, and ticking them would show prices that don't exist. The feed is framework-free (src/lib/live-quotes.ts), so it is unit-tested with a fake clock.
Every 3 seconds each symbol trades with a 60% chance. If it trades, its step s is uniform in [−0.004, +0.004], and the new price is p′ = p × (1 + s). If p′ would leave ±3% of the opening price a (|p′ / a − 1| > 0.03), the step is reflected, p′ = p × (1 − s), and clamped to the band if that still leaves it. The step is seeded by the symbol and the tick number, so the same symbol moves the same way in every entity: it is a market quote, not a book's. The band keeps a demo left open all afternoon believable.
The timer runs only while a live component is on screen and the tab is visible; the last listener leaving stops it:
React reads the feed through useSyncExternalStore (src/hooks/use-live-quotes.ts): useLiveQuotes(items) registers a section's positions and returns id → live price, recomputed on each tick.
One set of numbers per tick
The KPI strip, the chart header, the distributions, the movers and the holdings table must show the same figures at every tick. So each section calls useLiveBook with the book's snapshot positions and the same function the data layer uses to build its figures (equityBook, cryptoBook…); the positions are repriced with the live quotes and the figures rebuilt. It is called inside each section, never at page level, so a tick re-renders the sections that show prices and not the page around them. From src/features/investments/live.tsx:
repricePositions (src/lib/data/selectors/investments.ts) recomputes value, P&L, day P&L and weights from quantity × price, and returns the same array when no price moved. Each new price is also written into the data book (), and when the last live component leaves after prices moved, the cached figures refresh once. That is what lets Net worth value the investments at the prices last shown without ticking itself.
Formats applied app-wide
Every amount, number, percentage and date on screen goes through src/lib/format.ts: no toFixed, no string concatenation for money, the true minus sign (U+2212) for negatives, Intl formatters built once and cached (tables format thousands of cells), and date-only strings treated as calendar days, never shifted by a time zone. Prices have an adaptive precision: two decimals from 1, four from 0.01, three significant digits below that, so a coin at $0.0000121 doesn't read as $0.00.
Settings → General picks the date format (month first "Oct 31, 2026", day first "31 Oct 2026", ISO "2026-10-31") and the number format (1,234.56 · 1.234,56 · 1 234,56). Months keep their names in every format, and amount fields take a point for decimals in all of them. The formatters read module defaults, which applyFormatPreferences replaces:
Setting the defaults isn't enough on its own: text already on screen, and labels the selectors wrote into cached query results (insights, "since …" dates), would keep the old format. So FormatPreferencesProvider (src/components/format-preferences.tsx) sets the defaults before anything below it renders, remounts the app under a new key when they change, and invalidates the queries. The server and the hydrating render use the defaults (settings live in localStorage), so there is no hydration mismatch; saved formats take over right after hydration, with one remount, and only when they differ.
Time zone and language are saved and shown, not applied: dates and "today" follow New York time, and the interface is English, left to right. The README says where to wire them.
Prefetch on intent
Every route in Meridian is dynamic, on purpose: the root layout reads the theme customizer's cookie, so the server renders the right mode, preset, font, density and privacy state and the first paint never flashes. From src/app/layout.tsx:
Every route also has a loading.tsx (next section). Together, that made the default next/link expensive: each link in the viewport prefetched its dynamic route up to the loading boundary, 27 RSC requests on loading Finance and 26 on Equities, from the sidebar's links plus card and row links. Meridian's links now prefetch on intent. , a drop-in for used for every link in , passes until the pointer enters, the link takes keyboard focus or a finger touches it, then the default (); its code is in the lesson below.
Lazy charts
Recharts is the heaviest library in the app, so it stays out of every page's first-load JavaScript. src/components/charts/cartesian.tsx is a set of next/dynamic wrappers over cartesian-charts.tsx, which holds the charts. The chunk is requested as soon as a page that draws charts runs its JavaScript, so it arrives while the queries are still waiting on their latency; until it does, each chart shows the same silhouette, at the same height, as its loading card, so nothing moves when it draws. The Donut keeps its legend and centre total and only its ring waits (donut-ring.tsx); the bond ladder is split the same way.
First-load JavaScript (gzip, the scripts the HTML references): Finance 643 → 510 KB, Equities 649 → 516 KB; Settings, which has no charts, stays at 477 KB. Recharts (97 KB) and the chart kit arrive in parallel. Whether that delayed the data was checked directly: in 12 runs over Finance, Net worth, Equities and Insurance, the chunk arrived 0.6–1.7 s before the data did (Finance: chunk 1.02 s, data 1.92 s). The rule that keeps it this way: no statically loaded module may import recharts or ui/chart.
The kit's presets (TimeSeriesArea, MultiLine, GroupedBars, StackedBars, DivergingBars, MiniArea) sit on shadcn's ChartContainer, with the Y axis as wide as its longest label, compact ticks, a "View as table" for every chart, and first-draw-only animation that stops under reduced motion. The Loans page's prepayment simulator draws its before and after balances with MultiLine on one axis, which is where the next lesson showed:
The Loans page's prepayment simulator in dark: interest and time saved, and the balance before and after on one axis
Route-level loading.tsx, and the 404 in a layout
Each of the 21 routes inside the shell has a loading.tsx that renders RouteSkeleton in one of six layouts (overview, table, detail, form, settings, lists), so a navigation never shows an empty panel before the page's own skeletons take over: transactions/loading.tsx is one line of JSX, <RouteSkeleton layout="table" />.
The 404 is designed and lives inside the shell, so an unknown URL keeps the sidebar and header (src/app/not-found.tsx):
A bad parameter is a 404 too: /settings/nope and /invoices/view/abc. The check sits in each segment's layout, not in its page, because of the next lesson (whose snippet is the Settings layout). From src/app/(app)/invoices/view/[id]/layout.tsx:
Design tokens, and contrast that is measured
src/app/globals.css defines every colour, radius, type size and motion duration as a CSS variable, for light and dark, and maps them to Tailwind utilities (bg-card, text-positive-text, text-kpi, p-card, h-row). Colours are written in OKLCH, and many carry their measured contrast in a comment (light muted text is 5.28:1 on a card). The brand group of the light :root:
The four presets (Meridian, Midnight, Graphite, Aurum) swap only the brand group. Their selectors aren't tied to <html>, so any element can preview a preset, which is how the customizer draws its swatches; each restates its dark values under .dark[data-preset], which outranks .dark.
The theme customizer open on Net worth, with the Aurum preset picked
The contrast check
pnpm check:contrast (scripts/check-contrast.mjs) reads globals.css itself, resolves every token in both modes and all four presets, converts OKLCH to sRGB, and computes the WCAG 2.2 ratio for 204 required pairs: text at 4.5:1, the focus ring at 3:1. It exits 1 on any failure. Translucent layers are composited first, because the worst case is often a blend: the hero's tinted corner under its label, or <Money>'s translucent cents.
The pair lists name those blends as top over bottom: ["muted-foreground", "primary/hero over card"] is muted text on the preset colour at --hero-strength over the card, and foreground/money is a figure's cents at --money-minor-opacity.
Measuring changed the palette. Four token values from the brief were below AA once measured and were darkened: light tick labels (3.64 → 4.85:1), dark destructive under white text (4.20 → 5.00:1), Midnight's dark primary (3.58 → 4.78:1) and Aurum's light focus ring (3.56 → 4.20:1, non-text). The hero's tint is 8% in light mode, because the brief's 14% took muted labels in its corner down to 4.04–4.33:1 depending on the preset, and 's symbol and cents sit at 65% opacity, because 60% gave 4.32–4.43:1 on the hero. The final run: "204 required pairs checked, 0 below AA". The same maths runs live on in the browser, and the two agree.
The shell
Every page lives in an inset panel that scrolls on its own under a sticky header. The sidebar is shadcn's Sidebar variant="inset" collapsible="icon": an entity switcher, navigation with badges ("3 pending", "1 overdue") derived from the selected entity's book, groups remembered in a cookie so a closed group paints closed, and an icon rail (⌘B). The header has the period picker, a privacy toggle that blurs every amount, Ask Meridian (answers computed by the pages' selectors), notifications and the customizer; ⌘K opens a cmdk palette that navigates, runs actions (?sheet=new) and finds invoices, transactions and holdings.
Sheets and forms
Creating and editing happens in sheets: FormSheet is a 480 px sheet on desktop and a bottom drawer on phones (vaul), with react-hook-form and zod inside, and pickers built as a Combobox on cmdk that offers "Create …" for a new name. Every payment (a premium, an EMI, a prepayment, an invoice payment) goes through one PaySheet with a "Pay from" account and a total line, and books through dataActions, so the account it names really moves.
Testing: unit, smoke and axe
The last full run, once, on a fresh production build: pnpm test:unit 63 passed in 10.3 s, and playwright test tests/e2e --workers=2 521 passed, 0 failed, 0 flaky in 38.3 minutes. By spec: a11y 90, smoke 93, phase7-visual 56, responsive 44, shell 33, shell-visual 28, money-visual 28, investments-visual 28, kit-visual 20, phase7 19, money 16, overview 16, investments 13, overview-visual 12, kit 10, phase8 8, appearance 7. Playwright drives the installed Chrome and downloads no browser of its own; the unit tests run under Playwright too, without a server (playwright.unit.config.ts).
Smoke visits every route in light and dark at 1440 and 390 px: the page's heading and breadcrumb, the theme class on <html>, no console errors, no horizontal page scroll, and a full-page screenshot to look at (tests/e2e/smoke.spec.ts):
axe runs over 45 targets in both modes (90 tests): every route, and the states that only exist after a click — sheets open with their validation shown, rows selected, a chart turned into a table, every Settings tab, the 404 and the error page. It fails on any serious or critical violation (tests/e2e/a11y.spec.ts):
Responsive loads each of the 22 routes once per mode and steps the viewport through 1536, 1440, 1280, 1024, 768 and 390 px, asserting that the document, the shell's scroller and #main never scroll sideways. Unit tests pin the data layer (above), the walk and the feed, the formatters, and the loan and invoice maths. Pages with live prices are tested with the feed frozen, and two more hooks exist for tests: window.__meridianFailRender = 1 makes the Settings page throw after it mounts, to show the error page, and ?print=1 opens an invoice's print dialog.
The screenshots caught what the tests didn't: an expense delta compared against the wrong days, net-worth markers running off the plot, a select-all box showing a full tick, invisible skeletons, hero figures cut to "$119.…" in a phone's half-width tile. The tests caught what the eye didn't: a CSV with only its header, a "Create …" row that never appeared and a 404 page served with status 200.
Finance overview, Equities live in dark, and invoice #5099 on three phones
What the listing pictures found
The listing's pictures were shot at 1440 × 900 after phase 8, and they found three layout faults that every suite had passed. One was the app's own markup: a chart card's "…" menu wrapped alone under its description because it was the last item of a single flex-wrap row; chart-card.tsx now keeps the title and actions in a wrapping column of their own, with the menu outside it. The other two are lessons below, and each now has a test that fails on the old build (in investments.spec.ts and shell.spec.ts).
Takeaways
Generate the ledger from the records and steer each month with one balancing row; derive every KPI with a selector; pin both the reference figures and the invariants in unit tests.
Put the mock behind the hooks a real API would use: one fetchFromBook to swap, a key per entity and filter, mutations that invalidate.
Make every payment a transaction with an undo, so balances, histories and net worth can't drift apart.
For live data, seed the walk by symbol and tick, bound it, run it only while it's watched, reprice per section with the data layer's own function, and animate only opacity and transform.
On dynamic routes with a loading.tsx, prefetch on intent and call notFound() in a layout.
Measure contrast from the tokens file, composites included, fail the build below AA, and keep raw colours out of the source with a guard.
Shoot the pages at real widths: the pictures found faults that every test passed.
Make it yours
The release ZIP is the whole Next.js project: pnpm install, then pnpm dev.
Your data: replace fetchFromBook in src/lib/queries/client.tsx with a fetch to your backend, and point each dataActions call in src/lib/data/store.ts at a mutation.
Your name and links:src/lib/site-config.ts; every wordmark, page title, invoice and e-mail template follows it.
Your colours: a [data-preset] block in globals.css and an entry in presets (src/lib/appearance.ts), then pnpm check:contrast.
The demo:NEXT_PUBLIC_DEMO_TODAY=2026-10-28 freezes "today" and NEXT_PUBLIC_DEMO_FAST=1 drops the latency; /dev/tokens and /dev/kit show every token and component and answer 404 in production.
Left to you by design: nothing runs on its own date (reminders, auto-send and autopay are stored, not executed), a reload resets the book, entities keep their own currencies without FX, and the saved time zone and language aren't applied yet.
How Parley keeps the open chat in the URL under Next 16, and ships its 22 Base UI primitives inside the component, under UpFork's 400,000-character cap.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork·6 min read··AI
3 lessons
Behind the build
Behind the build · Wisp — AI Chat App Template for Next.js, shadcn/ui & Tailwind (Streaming, Research, Canvas)
How Wisp keeps a 520-message chat scrolling with Shiki in a Web Worker, persists to IndexedDB without crashing next start, and eight traps with blur, scrollbars and code blocks.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork·7 min read··AI
8 lessons
If a date range picked in react-day-picker lands in the URL one day early (Oct 1 → 2026-09-30) through nuqs parseAsIsoDate, use a createParser that reads and writes the local calendar day.
Snippet
Check
Playwright "a custom period is stored as the calendar days picked, in any time zone" clicks the 1st and 15th of the current month in the range calendar and expects from=2026-10-01&to=2026-10-15 in the URL → passed (Chrome at UTC+3:30).
Applies to
nuqs@>=2.10 <3, react-day-picker@>=10 <11
I assumed
parseAsIsoDate round-trips a calendar day ("2026-10-01") for any Date the picker returns.
What happened
probe on a machine at UTC+3:30 (Chrome 156): parseAsIsoDate.serialize(new Date(2026, 9, 1)) → 2026-09-30, while parseAsIsoDate.parse("2026-10-01") gives local Oct 1. A custom period Oct 1 – Oct 15 would have been stored as from=2026-09-30&to=2026-10-14.
Why
serialize uses toISOString().slice(0, 10) (UTC), and react-day-picker hands back local midnight, which is the previous UTC day east of Greenwich (west of it, parse shows the previous day instead).
The fix
parseAsCalendarDate (snippet) in src/hooks/use-workspace.ts for ?from= and ?to=.
the same test → passed; the downloaded transactions-2026-10-28.csv has the header Date,Merchant,Group,Category,Account,Amount,Status and only Income rows while the Income view is active.
the row models are views over the same Row objects, so the sorted rows can be intersected with the filtered rows to export "every matching row, in screen order".
What happened
the CSV export (the toolbar button and the palette's meridian:export-table event) wrote only the header. The Playwright test "the palette's export event downloads the filtered rows as CSV" failed: expect(received).toBeGreaterThan(expected) Expected: > 10 Received: 0 (lines after the header).
Why
table-core's getSortedRowModel copies every row before sorting — const sortedData = rows.map(row => ({ ...row })) in build/lib/utils/getSortedRowModel.js — so no sorted row is === a filtered row.
The fix
const rows = table.getPrePaginationRowModel().rows — the model after filtering, grouping and sorting, before pagination.
Evidence
tests/e2e/kit.spec.ts before/after; node_modules/@tanstack/table-core@8.21.3 source line quoted above.
Rule
If pnpm lint warns react-hooks/incompatible-library on useReactTable, "use no memo" won't silence it: disable that line with a reason. For react-hook-form's watch(), use useWatch({ control, name }).
Snippet
Check
pnpm lint → exit 0, nothing printed after $ eslint (before: 4 problems, then 1).
the warning means "the React Compiler will skip this component", so marking the component with the opt-out directive "use no memo" answers it. (The compiler isn't even enabled in this project's next.config.ts.)
What happened
pnpm lint → warning Compilation Skipped: Use of incompatible library … TanStack Table's useReactTable() API returns functions that cannot be memoized safely react-hooks/incompatible-library, and the same for react-hook-form's watch(). With "use no memo" first in DataTable, the table's warning stayed: ✖ 1 problem (0 errors, 1 warning).
Why
the rule reports a listed library's call site whatever the directive says. UpFork lesson L-X6NA turns this rule off when the compiler is off; this one is for keeping it on: the directive doesn't answer it, and for react-hook-form useWatch, a subscribing hook, removes the flagged call.
The fix
the form reads useWatch({ control: form.control, name: "direction" }); DataTable keeps "use no memo" (a buyer who enables the compiler gets no stale rows) and the eslint-disable line with its reason.
Evidence
lint output at each step (4 problems, then 1, then 0), 2026-10-28.
Snippet
Check
qa/kit-table-bulk-*-1440.png re-shot after the change shows a filled box with a dash for 2 selected rows; the kit test still selects every row with the same box ("10 selected") → passed.
Applies to
shadcn@>=4.23 <5, radix-ui@>=1.4 <2
I assumed
Radix's Checkbox handles checked="indeterminate" end to end, so the shadcn wrapper shows a mixed state.
What happened
with 2 of 10 rows selected, the DataTable header checkbox (checked={table.getIsSomePageRowsSelected() ? "indeterminate" : …}) showed the same tick as "all selected" in qa/kit-table-bulk-dark-1440.png. The root's fill classes only target data-[state=checked], and the indicator's only child is <CheckIcon />.
Why
Radix renders the Indicator for both checked and indeterminate; what it shows is up to the wrapper. This is the Radix counterpart of UpFork lesson L-DVXR, which found the same gap in shadcn's Base UI checkbox.
The fix
group/checkbox on the root, data-[state=indeterminate]: fill classes like the checked ones, and both icons in the indicator (snippet).
Evidence
screenshots before/after, 2026-10-28 (new-york-v4 checkbox.tsx from the registry).
dataActions.setQuote
Equities with live prices: the holdings table's prices and day returns ticking, delayed 15 minutes
What a tick costs
Measured on the production build in installed Chrome at 1440 px, 30 s per run (10 ticks). In the first measurement Equities spent 6.2 s of task time per 30 s against 0.37 s of script: the live dot's pulse animated box-shadow, and the flash animated background-color in every cell that moved, both repainting every frame. Both became pseudo-elements animating only opacity and transform, which the compositor runs: 6.2 s → 2.6 s (0.86 s with reduced motion). At that point a tick re-rendered about 1,460 components in 2 commits on Equities, with ~40–55 ms of script. The flash, from src/app/globals.css:
The rest was layout: meters and ranked bars animated their width on every tick. They now slide a full-width fill by transform inside a clipping track (translateX, not scaleX, which would squash the rounded end), and the holdings tables memoise the name cell and the row menu on primitives (the row menu was a full Radix menu per row, re-rendered on each tick). From src/components/kit/meter.tsx:
That second pass, measured over 30 s of ticks on Equities: task time 3.15 → 1.54 s, script 0.66 → 0.37 s, layout 0.30 → 0.02 s, and about 693 → 570 components per commit.
Tests and screenshots freeze the feed: window.__meridianFreezeQuotes = true before the page loads stops the timer, and window.__meridianQuotes.tick() moves it one step. The walk itself is unit-tested over 20,000 ticks for four symbols: no step above 0.4%, no drift past 3%, and a drift past half the band, so it does use it.
Check
pnpm exec playwright test --config playwright.unit.config.ts tests/unit/investments.spec.ts → "10 passed (7.0s)"; the whole unit suite → "52 passed (14.0s)".
Applies to
@playwright/test@>=1.40
I assumed
a synchronous expect(x).toBeLessThanOrEqual(y) costs about as much as the comparison, so 4 symbols × 20,000 ticks × 2 checks is nothing.
What happened
pnpm test:unit was killed by its 400 s timeout; run alone, the spec ended with "the walk is bounded … Error: worker process exited unexpectedly (code=143, signal=null)" after 4.9 min while the other 8 tests had passed.
Why
not traced into Playwright's source. Measured alone: 20,000 expect(Math.abs(Math.sin(i))).toBeLessThanOrEqual(1) calls took 58,080.8 ms, the loop without expect 9.1 ms, about 2.9 ms a call (other specs running); each expect being reported as a step is the likely cost.
The fix
track widestStep and widestDrift in the loop and assert them once (plus a check that the walk uses the band).
Evidence
4.9 min and killed → 7.0 s for the spec; the micro-measurement above (a throwaway spec, deleted after); Node 22.
src/components/kit/intent-link.tsx
next/link
src/
prefetch={false}
null
Measured on the production build at 1440 px: RSC requests on loading Finance / Equities 27 / 26 → 0 / 0, and a sidebar click to the next page's heading 475 / 438 ms → 304 / 265 ms, because hovering the link prefetched it. The test, in tests/e2e/phase8.spec.ts:
Snippet
Check
tests/e2e/phase8.spec.ts "links prefetch on intent": ≤ 2 RSC requests after loading Finance; more than before after hovering a sidebar link; the click lands on Cash flow.
Applies to
next@>=15 <17
I assumed
the default Link costs little (a dashboard's routes are cheap to prefetch), and prefetch={false} keeps the hover prefetch, as in the Pages Router.
What happened
every route is dynamic (the root layout reads the appearance cookie) and has a loading.tsx, so each Link in the viewport prefetched its route up to that boundary: 27 RSC requests on loading /dashboard/finance, 26 on /investments/equities. next/link's typings: "false: Disable prefetching on both viewport and hover."
Why
in the App Router, a dynamic route is partially prefetched (to the nearest loading.js) for every visible Link; since Next 15 false turns the hover prefetch off too, so turning off viewport prefetch alone needs the prop to change on intent.
The fix
IntentLink (snippet, components/kit/intent-link.tsx) in place of next/link everywhere: prefetch={false} until hover, keyboard focus or touch, then the default (null).
Evidence
production build at 1440: RSC requests on load 27 / 26 → 0 / 0 (Finance / Equities); sidebar click → next page's h1 475 / 438 → 304 / 265 ms, 2026-10-30.
Check
the simulator sheet at 1440 (qa/p8/c-emis_sheet_simulator-light-1440x1400-part.png): "Oct 2043" ends at the plot's right edge, fully drawn.
Applies to
recharts@>=2 <4
I assumed
with explicit ticks, interval={0} simply shows all of them, and Recharts keeps tick labels inside the chart as it does by default.
What happened
the prepayment simulator's balance chart (MultiLine, 5 even ticks, right margin 12px) drew "Oct 2043" centred on the last point, half past the plot's right edge, where the sheet clipped it (screenshot review, light 1440).
Why
for a numeric interval recharts/lib/cartesian/getTicks.js returns getNumberIntervalTicks(ticks, interval), each label centred on its point with no fitting; for "preserveStart" / "preserveStartEnd" it measures each label and moves it in (tickCoord = coordinate - gap) when it would overflow.
The fix
the chart kit's XAxis (MultiLine, TimeSeriesArea): interval="preserveStartEnd" with its even ticks.
Evidence
the review screenshot before; the shot above after; getTicks.js lines 138–141 and 172–173.
Snippet
Check
pnpm build, then tests/e2e/phase7.spec.ts "system pages…": /settings/nope and /invoices/view/abc → response.status() 404 and the 404 page's h1; /no-such-page → 404.
Applies to
next@>=15 <17
I assumed
notFound() in a server page.tsx always gives the 404 status, as it did before the route had a loading.tsx; only the HTML would differ.
What happened
after every (app) route got a loading.tsx (route-level skeletons), page.goto("/settings/nope") returned Expected: 404 Received: 200 — the designed 404 rendered, but with status 200. An unmatched URL (/no-such-page) still returned 404.
Why
loading.tsx wraps page.tsx in a Suspense boundary; Next flushes the layouts and the loading fallback with a 200 before the page renders, so a notFound() thrown inside the page can only swap the HTML (Next adds a robots noindex meta), not the status already sent. The docs describe this for streaming in general; nothing on loading.js warns that it moves a page's notFound() behind the flush.
The fix
the tab check moved to settings/[[...tab]]/layout.tsx and the invoice id check to invoices/view/[id]/layout.tsx: a layout renders before the loading boundary, so its notFound() lands before the first byte.
Evidence
phase7.spec run 1: "Expected: 404 Received: 200" at expect(unknownTab?.status()).toBe(404); after the layout check, rebuilt: "2 passed (14.7s)" with both statuses 404.
<Money>
/dev/tokens
The token guard
pnpm check:tokens (scripts/check-tokens.mjs) fails on a hex colour, a colour function or a Tailwind palette class anywhere under src/, comments included, so components can only colour through tokens. It ended at 285 files with no raw colour.
If a custom --text-* size vanishes when merged with a text colour, register it with createCn from "cn/config", and alias "cn" in tsconfig paths so shadcn's components use it too.
Snippet
Check
probe after the fix → "text-caption text-muted-foreground", "text-positive-text text-display", "p-4"; in Chrome, the shadcn Button with className="text-caption" renders classes … text-primary-foreground … text-caption (its text-sm merged away), computed font-size 12px; pnpm typecheck and pnpm build pass with the alias.
Applies to
cn@>=0.5 <1, tailwindcss@>=4 <5, shadcn@>=4.22 <5
I assumed
a font size defined in Tailwind's theme (--text-caption: 0.75rem with line height, weight, tracking) merges like text-sm: cn("text-caption", "text-muted-foreground") keeps both.
What happened
in Node, cn("text-caption", "text-muted-foreground") → "text-muted-foreground", cn("text-kpi", "text-positive-text", "text-display") → "text-positive-text text-display" and cn("p-card", "p-4") → "p-card p-4" (both kept, CSS order decides). An unknown text-x lands in the text-colour group.
Why
cn reproduces tailwind-merge's conflict rules, which classify classes by name, not by reading your CSS. Configuring @/lib/utils alone isn't enough: shadcn 4.22+ components import { cn } from "cn" directly.
The fix
createCn from "cn/config" in src/lib/utils.ts, taught the custom names (the font sizes, spacing: ["card", "row"], max-w-content, inset-shadow-highlight), and tsconfig paths mapping the bare "cn" to that file (an exact match, so "cn/config" still resolves to the package).
Evidence
Node probe before and after; getComputedStyle of [data-testid=cn-alias-check] on /dev/tokens, 2026-10-28.
Snippet
Check
parseColor("lab(98.248% -.0918508 -1.13485)") and parseColor("oklch(0.985 0.003 264)") both give #f9fafc and the same ratio against white (1.0441); the live table on /dev/tokens then shows the same ratios as the Node check over globals.css (muted-foreground on card 5.28:1, chart-axis on card 4.85:1).
Applies to
next@>=16.4 <17, tailwindcss@>=4.3 <5
I assumed
getComputedStyle(document.documentElement).getPropertyValue("--background") returns the value as written in globals.css, oklch(0.985 0.003 264).
What happened
it returned lab(98.248% -.0918508 -1.13485), so a contrast table that parsed only oklch(), rgb() and hex showed nothing. The served CSS had :root { --background: #f9fafc; … } and then @supports (color: lab(0% 0 0)) { :root { --background: lab(98.248% …) } }: 220 lab( and 2 oklch( in the stylesheet.
Why
the CSS is lowered for the configured browser targets (Lightning CSS): oklch() becomes a hex fallback plus lab() behind @supports, and Chrome takes the lab() branch.
The fix
parseColor (src/lib/contrast.ts) also reads lab() (CIE Lab D50 → Bradford → linear sRGB, per CSS Color 4) and oklab(), with numbers like -.09 and 1e-5.
Evidence
fetched CSS chunk and probe output, 2026-10-28 (Next.js 16.4.3 with Turbopack, Chrome 156).
@source not "../../docs"
Snippet
Check
after a reload, none of --color-green-500, --color-emerald-600, --color-blue-500, .bg-green-500, .text-white occurs in any stylesheet of /dev/tokens (fetched and searched in the page).
Applies to
tailwindcss@>=4.1 <5
I assumed
Tailwind generates utilities from the app's source files.
What happened
the served CSS held --color-green-500: lab(70.5521% -66.5147 45.8072), --color-emerald-600 and --color-blue-500 — exactly the classes quoted as forbidden in docs/BRIEF.md (§14.1) and in the comment of scripts/check-tokens.mjs.
Why
Tailwind documents this as automatic source detection: it scans every non-ignored text file in the project, Markdown and scripts included. The trap is the project's own instructions. A build brief, a plan or a guard script that quotes the banned classes, common in projects built with an AI assistant, makes Tailwind generate exactly the classes they forbid.
The fix
in src/app/globals.css, @source not "../../docs"; @source not "../../scripts"; @source not "../../tests"; @source not "../../qa";.
Evidence
stylesheet search before (3 variables found) and after (0), 2026-10-28.
If boxes using your own shimmer utility render invisible, shadcn/tailwind.css defines shimmer too (background-clip: text) and Tailwind merges both: give yours another name.
Snippet
Check
the rebuilt CSS's .skeleton-shimmer{…} rules contain no background-clip; re-shot qa/kit-kpis-light-1440.png and qa/dev-kit-dark-390.png show the loading card's two bars and the skeleton lines; the 8 re-run screenshot tests passed.
Applies to
shadcn@>=4.22 <5, tailwindcss@>=4 <5
I assumed
a utility declared with @utility shimmer { … } in my globals.css is mine; the name was free because nothing in the project used it.
What happened
every skeleton (class="shimmer rounded-md bg-muted …") was invisible in light and dark, though it had its size and a muted background-color. The built CSS held four .shimmer{…} rules: mine and shadcn's background-clip:text; -webkit-background-clip:text; -webkit-text-fill-color:var(--shimmer-text-fill,transparent); animation:tw-shimmer …. Clipped to text, with no text inside, nothing painted.
Why
@import "shadcn/tailwind.css", which shadcn's variants and keyframes need, declares @utility shimmer (node_modules/shadcn/dist/tailwind.css, line 547 in 4.22.2). Tailwind 4 doesn't warn about a second @utility of the same name; it emits both under one class.
The fix
renamed mine skeleton-shimmer (and its keyframes) and used it in the Skeleton component.
Evidence
grep -o "\.shimmer{[^}]*}" .next/static/chunks/*.css before, the .skeleton-shimmer rules after, 2026-10-28.
If eslint-config-next 16 fails a fresh shadcn sidebar.tsx with "Cannot call impure function during render" on Math.random, derive the skeleton width from React.useId() instead.
Snippet
Check
pnpm exec eslint --quiet → no output, exit 0 (before: 3 errors, one of them this).
components straight from the shadcn registry pass the Next.js lint preset (the scaffold only kept the Tailwind class-order rules off components/ui).
What happened
pnpm lint → src/components/ui/sidebar.tsx 610:26 error Error: Cannot call impure function during render. Math.random is an impure function … react-hooks/purity, in SidebarMenuSkeleton's React.useMemo that builds a random 50–90% width from Math.floor(Math.random() * 40) + 50.
Why
eslint-plugin-react-hooks 7 (bundled by eslint-config-next 16) ships the React Compiler rules, and purity is an error in its recommended set. A random width also differs between the server render and hydration.
The fix
the width comes from React.useId() (sum of its character codes, modulo 41, plus 50) — stable per instance, identical on server and client (snippet).
Evidence
lint output before/after, 2026-10-28.
Snippet
Check
the ⌘B test then hovered the Transactions icon on the rail and read the tooltip "Transactions · 3 pending" → passed.
Applies to
shadcn@>=4.23 <5
I assumed
an opacity-0 label is out of the way on the rail.
What happened
on the sidebar with collapsible="icon", locator('a[href="/transactions"]').hover() timed out: <div data-sidebar="group-label" …>Money management</div> … subtree intercepts pointer events. The Payments group's only icon could not be hovered (no tooltip) or clicked.
Why
on the rail SidebarGroupLabel gets -mt-8 opacity-0; the 32px label moves up over the bottom of the group above it. Opacity doesn't stop hit-testing.
The fix
group-data-[collapsible=icon]:invisible on the label, with visibility added to its transition list so it still fades out before hiding.
Evidence
Playwright call log above, 2026-10-28.
Check
the same test → passed (open with Control+k, navigate, open with Meta+k, find #5099, Control+k opens, Control+k closes).
Applies to
cmdk@>=1.0 <2
I assumed
a window keydown listener that skips event.defaultPrevented events sees Ctrl+K the same way inside and outside the palette.
What happened
the shell test "⌘K and Ctrl+K open the palette…" (Chrome 156 on Windows, shadcn's CommandDialog) failed at await expect(palette).toBeHidden() after a second Control+k: the dialog stayed open. Meta+k toggled fine.
Why
cmdk's onKeyDown has case "k": vimBindings && e.ctrlKey && prev(e), and prev starts with e.preventDefault() (it moves the selection up). vimBindings defaults to true; Ctrl+N/J/P are taken the same way.
The fix
in the shell's keydown listener the ⌘K / Ctrl+K branch now runs before if (event.defaultPrevented) return.
Evidence
node_modules/cmdk/dist/index.mjs (case"k":{j&&e.ctrlKey&&se(e) with se=e=>{e.preventDefault(),…}); test output before/after, 2026-10-28.
If page.locator("main") hits 2 elements in a shadcn sidebar layout, SidebarInset is already a : make it a div and keep your own main#main as the one main landmark.
Snippet
Check
tests/e2e/phase8.spec.ts "every page has exactly one main landmark": getByRole("main") has a count of 1 on all 22 routes (the 21 pages and the 404) → passed; axe on every route: no serious or critical violation.
Applies to
shadcn@>=4 <5, @playwright/test@>=1.40
I assumed
SidebarInset was a styled wrapper, so the shell's <main id="main"> (the skip link's target) was the page's only main landmark.
What happened
expect(page.locator("main")).toContainText(…) failed: "strict mode violation: locator('main') resolved to 2 elements: 1) <main data-slot="sidebar-inset" …> 2) <main id="main" tabindex="-1" …>". The axe spec (serious and critical only) had never flagged it.
Why
the registry's SidebarInset takes React.ComponentProps<"main"> and returns <main data-slot="sidebar-inset" …>; shadcn's Sidebar page says to use it "to wrap your main content" without saying it is itself a main.
The fix
SidebarInset renders a <div> (the edit is marked Meridian: in components/ui/sidebar.tsx), so the shell's <main id="main"> is the one main landmark; the header and footer became banner and contentinfo landmarks.
Evidence
tests/e2e/overview.spec.ts:53 on 2026-10-28 (the error above); the final full run on 2026-10-30, 521 passed, the one-main test among them.
Snippet
Check
the same test then created "Larkhaven Studio", saved the transaction and found it by search (1 row, "1,250.50") → passed.
Applies to
cmdk@>=1.1 <2
I assumed
the item's docs say forceMount means "forcibly rendered regardless of filtering", so a create row in its own group appears for any search text.
What happened
in the add-transaction sheet's counterparty combobox (shadcn new-york-v4 command.tsx, Chrome 156), typing a new name left an empty list. The Playwright test timed out (180 s) at waiting for getByRole('option', { name: /Create “Larkhaven Studio”/ }); the page snapshot showed combobox [expanded]: Larkhaven Studio above an empty listbox "Suggestions".
Why
cmdk decides a group's visibility from its matching items; an item that is only force-mounted doesn't make its group visible, so the item renders inside a hidden group. The Group has its own forceMount prop ("Whether this group is forcibly rendered regardless of filtering").
The fix
forceMount on the group as well as the item (snippet), the row rendered only while the query is non-empty and matches no option exactly.
Evidence
test output and page snapshot before, test pass after, 2026-10-28 (src/components/kit/combobox.tsx).
If getByLabel can't find a field inside a shadcn FormItem, make the component under accept the rest props and spread them on the real (id, aria-describedby, aria-invalid).
Snippet
Check
the same spec, rebuilt: getByLabel("Quantity").fill("10") works and "add investment: validates, buys…" passed; the axe run over the sheet with validation shown reports no label violation.
a component inside is labelled by the above it, like Input, MoneyInput and DatePicker are.
What happened
locator.fill: Test timeout of 120000ms exceeded. - waiting for getByRole('dialog', { name: 'Add investment' }).getByLabel('Quantity'). The quantity field (an with a unit after it, wrapped in a div) rendered without an id; the pointed at nothing.
Why
FormControl is <Slot.Root id={formItemId} aria-describedby=… aria-invalid=… {...props}>: Slot merges those props into its single child element — here the QuantityInput component, whose props type didn't take them, so they never reached the .
The fix
QuantityInput takes the input's props (minus value, onChange, onBlur) and spreads the rest on the .
Evidence
the timeout above, then "4 passed (38.4s)" for the tests that use the field.
a useCallback that builds form defaults, with every dependency listed, is lint-clean (exhaustive-deps was satisfied).
What happened
pnpm lint → 6 errors "Compilation Skipped: Existing memoization could not be preserved … This dependency may be modified later, which could cause the value to change unexpectedly" on deps like [latest, primary?.id]; after hoisting primaryId, two more on React.useMemo(() => makeSchema(earliest), [earliest]) with earliest = addDays(latest, 1), while an earlier sheet's useMemo(() => makeSchema(latest), [latest]) passes.
Why
the React Compiler's validation (run by the lint rule even with the compiler off) only keeps a manual memo whose dependencies it can prove don't change after the memo; a property read through ?. on an object from a query, or a value it treats as possibly mutable, fails that proof. Why addDays differs from workspaceDay here is not clear.
The fix
hoist the primitive (primaryId), make the defaults plain functions, and build the schemas per render (const schema = makeSchema(earliest), cheap).
Evidence
lint output quoted above (6 errors → 2 → 0).
If a shadcn bottom Drawer's header stays centred despite className="text-left", it centres with group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center: override that same variant.
Snippet
Check
/transactions?sheet=new at 390 px: the "Add transaction" title and its description start at the drawer's left padding (qa/p8/c-transactions_sheet_new-light-390-part.png).
Applies to
shadcn@>=3, vaul@1
I assumed
cn("… group-data-[…]:text-center …", "text-left") lets the later text-left win, like any other conflicting utility.
What happened
on phones FormSheet (the create / edit sheets as a full-height drawer) showed its title and description centred while the desktop sheet had them left-aligned (found in the screenshot review).
Why
cn (tailwind-merge rules) only treats classes as conflicting when their variants match; text-left and group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center both stay, and the variant's selector (an ancestor attribute plus the class) out-ranks the plain class.
The fix
FormSheet's DrawerHeader adds group-data-[vaul-drawer-direction=bottom]/drawer-content:text-left (and the sheet's 20px padding).
Evidence
drawer.tsx line 79's class list; the 390 px shot after the fix, 2026-10-30.
a fresh Next.js page with no images or fetches loads without a single failed request.
What happened
both page tests failed: + "Failed to load resource: the server responded with a status of 404 (Not Found)". A page.on("response") listener recording every status ≥ 400 recorded nothing.
Why
likely the browser's automatic favicon request, which is not one of the page's network events; the app declared no icon. Adding one made the error go away.
The fix
added src/app/icon.svg (the brand monogram); Next emits <link rel="icon" href="/icon.svg?…"> and Chrome no longer asks for /favicon.ico.
Evidence
Playwright output before and after, 2026-10-28 (installed Chrome 156.0.8078.48, channel "chrome").
relative
sr-only
Snippet
Check
re-shot qa/page-finance-light-390.png and …-dark-390.png are 390 px wide (were 710); the phone test asserts the rail scrolls inside itself (scrollWidth > clientWidth) and the page doesn't.
Applies to
tailwindcss@>=3 <5
I assumed
an element with overflow-x: auto clips everything inside it, so three 85%-wide insight cards on a 390px phone only scroll inside their rail.
What happened
the full-page 390px screenshot of /dashboard/finance came out 710 × 3821 px: with the shell's panel opened up for the shot (overflow: visible), document.documentElement.scrollWidth was 710, and only the cards' span.sr-only (position: absolute) sat outside the rail's clip. The live page didn't scroll sideways (the panel's own overflow hid it), so the overflow check passed.
Why
an overflow container clips an absolutely positioned descendant only when it is, or contains, that descendant's containing block. The rail had no position, so the spans were placed against the nearest positioned ancestor (SidebarInset, relative) — outside the rail.
The fix
relative on the rail (features/finance/finance-kpis.tsx).
Evidence
probe on 2026-10-28: "span.sr-only right=393 pos=absolute" for the second card; PNGs 710×3821 before, 390×3821 after.
Check
a probe of every tile with a chart on 19 routes at 1280/1440/1920 (1× and 2×), 1024, 768 and 390 (the five KPI pages also in dark): the column's text rects never meet the chart (the old build overlapped in every 1280/1440 capture of Equities, Crypto and Mutual funds).
Applies to
other: CSS flexbox (min-width: auto of flex items), tailwindcss@4
I assumed
min-w-0 on the delta column was the usual guard for truncation inside a flex row, and the shrink-0 sparkline (w-40 in the hero) always had room.
What happened
four KPIs across at 1280–1440 px left the hero's column 5–45 px wide; its "this month" (whitespace-nowrap) spilled 40–55 px into the sparkline on Equities, Crypto and Mutual funds. On Finance at 1280 px, "vs September" ended against Payment volume's chart (column 69 px for 80 px of words, the 12 px gap absorbed it).
Why
a flex item's automatic minimum width is its min-content width; min-w-0 replaces it with 0, so the column shrank below its words while the shrink-0 chart kept all of its 160 px.
The fix
stat-card.tsx (398da7e): the column has no min-w-0 and shrink-100 (it gives way first: the comparison wraps under the pill), the chart min-w-0 (it shortens after). Hero sparkline 100 px at 1280, 140 at 1440, 160 from 1920; where everything fits nothing moves.
Evidence
before/after crops of the Equities and Crypto tiles in light and dark at 1440 @2×; investments.spec "four KPIs across at 1280 and 1440px" fails on the old build ("text ends 55px into the sparkline").
Check
at the page's end: no footer link under the button at 1440/1024/768 in Compact and Full and at the XL scale; "Support" → button 31 px; at 1920 Compact and on phones the crops are byte-identical to the old build. shell.spec "at a page's end scroll-to-top sits clear" fails on the old build.
Applies to
other: CSS container query units (cqw) with percentage margins, tailwindcss@4
I assumed
the button, 20 px above the panel's bottom and 32 px in from its edge, cleared the one-line footer.
What happened
at a page's end it covered "Support" (37 px into the footer band) at 768, 1024, 1280 and 1440 px in the Compact layout and at every width in Full; at 1920 Compact the centred 1440 px row ended 80 px from the edge, so nothing touched.
Why
the footer row is max-w-content mx-auto; it reaches the edge whenever the panel is narrower than 1440 px plus its padding, or the layout is Full.
The fix
app-footer.tsx (398da7e): the footer is @container; the links' nav has mr-[max(0px,calc(3.75rem-(100cqw-100%)/2))]: the button's 2.25rem + its 2rem inset + a 1rem gap − the footer's 1.5rem padding, less the centring margin (100cqw = the footer's width, 100% = the row's).
Evidence
before/after crops at 1440 @2× light and dark; a Full-layout probe (old: −29 px, new: +31 px).
export function mulberry32(seed: number) { let state = seed >>> 0 return () => { state = (state + 0x6d2b79f5) >>> 0 let t = state t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 }}/** FNV-1a over the parts, so `createRng("ledger", "holdings", 3)` is its own stream. */export function hashSeed(...parts: (string | number)[]) { let hash = 0x811c9dc5 for (const char of parts.join("|")) { hash ^= char.charCodeAt(0) hash = Math.imul(hash, 0x01000193) } return hash >>> 0}
/** * Splits `total` into parts proportional to `weights`, in cents, so the parts add up to `total` exactly (the * rounding remainder goes to the largest part). */export function splitMoney(total: number, weights: readonly number[]): number[] { const totalCents = toCents(total) const weightSum = weights.reduce((sum, weight) => sum + weight, 0) || 1 const cents = weights.map((weight) => Math.floor((totalCents * weight) / weightSum)) const remainder = totalCents - cents.reduce((sum, value) => sum + value, 0) const largest = weights.indexOf(Math.max(...weights)) cents[largest] += remainder return cents.map(fromCents)}
// Balancing rows: one consulting payment and one vendor purchase make the month land on its targets.const [incomeTarget, expenseTarget] = monthTargets(k, ctx, fraction)const inMonth = drafts.filter((row) => row.date >= month && row.date <= on(31) && row.status !== "failed")const income = sumBy(inMonth.filter((row) => categoryById(row.categoryId).group === "income"), (row) => row.amount)const expense = -sumBy(inMonth.filter((row) => categoryById(row.categoryId).group === "expense"), (row) => row.amount)const client = inputs.clients[rng.int(0, inputs.clients.length - 1)]// Never let a balancing row turn into its opposite: early in a month the fixed bills alone pass the pro-rated// target, and older months' targets are only a trend. (On the reference day both rows sit far above the floor.)const floor = ctx.amount(rng.between(450, 1200))const incomeGap = Math.max(incomeTarget - income, floor)const expenseGap = Math.max(expenseTarget - expense, floor)add({ date: on(Math.min(18, maxDay)), time: "15:45", merchant: client.name, description: "Project milestone", categoryId: "consulting-projects", amount: money(incomeGap), accountId: acc("checking"), method: "Bank transfer", status: "completed" })add({ date: on(Math.min(19, maxDay)), time: "10:30", merchant: rng.pick(vendors[ctx.entityId]), description: "Wholesale order", categoryId: "vendor-purchases", amount: -money(expenseGap), accountId: acc("checking"), method: "Bank transfer", status: "completed" })
/** Income and expense targets of the month `k` months ago (excluding transfers), scaled to the entity. */function monthTargets(k: number, ctx: BuildContext, fraction: number): [number, number] { const s = ctx.profile.scale if (k === 0) return [money(34_600 * s * fraction), money(26_180 * s * fraction)] if (k === 1) return [money(30_838 * s), money(28_581 * s)] const rng = ctx.rng("targets", k) const income = 32_600 * (1 - 0.006 * (k - 2)) * rng.between(0.93, 1.07) const expense = 26_300 * (1 - 0.004 * (k - 2)) * rng.between(0.94, 1.06) return [money(income * s), money(expense * s)]}
// The ledger is the one costly collection (≈2,000 rows): built on first read, so views that only need records// (another entity's approvals in the notification centre) never pay for it.let ledger: Transaction[] | null = nullreturn { entityId, currency: ctx.currency, today, now, institutions: buildInstitutions(ctx), accounts, cards, get transactions() { ledger ??= buildLedger(ctx, { accounts, cards, loans, policies, invoices, clients, subscriptions, bills, goals, equities }) return ledger },
/** * An account's end-of-day balance for each of the last `days` days, oldest first: today's balance minus everything * booked since (the history is derived from the ledger, so it always ends on the balance shown). */export function balanceHistory(data: EntityData, accountId: string, days = 30): { date: ISODay; balance: number }[] { const account = data.accounts.find((candidate) => candidate.id === accountId) if (!account) return [] const rows = data.transactions.filter((row) => row.accountId === accountId && counts(row)) const points: { date: ISODay; balance: number }[] = [] let balance = account.balance for (let offset = 0; offset < days; offset++) { const date = addDays(data.today, -offset) points.push({ date, balance }) balance = sumMoney([balance, ...rows.filter((row) => row.date === date).map((row) => -row.amount)]) } return points.reverse()}
/** * Keeps today's balances true to the ledger when a row is booked or taken back (`direction` −1): an account moves by * the amount, a credit card's balance owed by its opposite, a debit card's linked account by the amount. Failed rows * never moved money. Balance histories are derived backwards from today's balance, so they stay consistent too. */function moveBalances(entityId: EntityId, row: Transaction, direction: 1 | -1) { if (row.status === "failed") return const data = getEntityData(entityId) const delta = row.amount * direction const account = data.accounts.find((candidate) => candidate.id === row.accountId) if (account) { account.balance = cents(account.balance + delta) return } const card = data.cards.find((candidate) => candidate.id === row.accountId) if (!card) return if (card.type === "credit") { card.balance = cents((card.balance ?? 0) - delta) return } const linked = data.accounts.find((candidate) => candidate.id === card.linkedAccountId) if (linked) linked.balance = cents(linked.balance + delta)}
for (const entityId of entityIds) { for (const day of [REFERENCE, new Date("2026-11-03T15:00:00Z"), new Date("2027-02-14T15:00:00Z")]) { test(`${entityId} on ${day.toISOString().slice(0, 10)}: totals reconcile, lists are sorted, nothing upcoming is past`, () => { const data = buildEntityData(entityId, day) // deterministic expect(JSON.stringify(buildEntityData(entityId, day).transactions)).toBe(JSON.stringify(data.transactions)) // ledger: sorted oldest → newest, never in the future, every group matches its category const keys = data.transactions.map((row) => row.date + row.time) expect(keys).toEqual([...keys].sort()) expect(data.transactions.every((row) => row.date <= data.today)).toBe(true) expect(data.transactions.every((row) => row.group === categoryById(row.categoryId).group)).toBe(true) // income rows are inflows, expense rows outflows (the balancing rows never flip sign) expect(data.transactions.filter((row) => row.group === "income").every((row) => row.amount > 0)).toBe(true) expect(data.transactions.filter((row) => row.group === "expense").every((row) => row.amount < 0)).toBe(true)
const fast = process.env.NEXT_PUBLIC_DEMO_FAST === "1"/** Simulated network latency, 250–600ms. */export function latency() { if (fast) return Promise.resolve() return new Promise<void>((resolve) => setTimeout(resolve, 250 + Math.random() * 350))}export async function fetchFromBook<T>(entityId: EntityData["entityId"], select: (data: EntityData) => T): Promise<T> { await latency() return select(getEntityData(entityId))}/** * A query over the selected entity's book (`?entity=`). The key is ["workspace", entity, domain, ...params], so each * entity and filter caches separately and a mutation invalidates them all. */export function useEntityQuery<T>( domain: string, params: readonly unknown[], select: (data: EntityData) => T, options: Omit<UseQueryOptions<T>, "queryKey" | "queryFn"> = {},) { const { entityId } = useWorkspace() return useQuery<T>({ queryKey: [queryRoot, entityId, domain, ...params], queryFn: () => fetchFromBook(entityId, select), // A new period, range or bucket keeps the figures on screen until the new ones arrive (§7.5: charts change without // re-drawing from a skeleton). A new entity doesn't: its book is in another currency, so it shows its skeletons. placeholderData: (previous, previousQuery) => (previousQuery?.queryKey[1] === entityId ? previous : undefined), ...options, })}
export function previousRange(range: DayRange): DayRange { const shift = comparisonShift(range) if (shift.whole) return { from: addMonths(range.from, -shift.months), to: addDays(range.from, -1) } if (shift.months) return { from: addMonths(range.from, -shift.months), to: addMonths(range.to, -shift.months) } const length = diffDays(range.to, range.from) + 1 return { from: addDays(range.from, -length), to: addDays(range.from, -1) }}
const exportCsv = React.useCallback(() => { // Filtered and sorted, before pagination: every matching row, in the order on screen. (Not // getSortedRowModel().rows filtered by identity: the sorted model holds shallow copies of the rows.) const sorted = table.getPrePaginationRowModel().rows downloadCsv( csvFileName(title, workspaceDay(today())), exportColumns.map((column) => column.columnDef.meta?.label ?? column.id), sorted.map((row) => exportColumns.map((column) => cellValue(row, column.id))), ) toast(`Exported ${formatNumber(sorted.length)} ${sorted.length === 1 ? "row" : "rows"}`, { description: `${title} as CSV` })}, [table, title, exportColumns])
export const TICK_MS = 3000/** The largest move in one tick, as a share (0.4%). */export const MAX_STEP = 0.004/** How far a quote may wander from its opening price, as a share (3%). */export const BAND = 0.03/** The chance that a symbol trades in a given tick. */export const TRADE_CHANCE = 0.6/** One tick's move for a symbol: 0 when it doesn't trade, else a share in [−MAX_STEP, +MAX_STEP]. Seeded. */export function walkStep(symbol: string, tick: number): number { const next = mulberry32(hashSeed("quote", symbol, tick)) if (next() >= TRADE_CHANCE) return 0 return (next() * 2 - 1) * MAX_STEP}/** The price after a step: a step that would leave the band is reflected, and the result is clamped to it. */export function nextPrice(anchor: number, price: number, step: number): number { const inBand = (value: number) => Math.abs(value / anchor - 1) <= BAND let next = price * (1 + step) if (!inBand(next)) next = price * (1 - step) if (!inBand(next)) next = Math.min(anchor * (1 + BAND), Math.max(anchor * (1 - BAND), next)) return next}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { // The customizer's choices arrive with the request, so the first paint already has the right theme, preset, // font, density and privacy state. "system" mode is resolved before paint by next-themes' inline script. const jar = await cookies() const appearance = parseAppearance(jar.get(APPEARANCE_KEY)?.value) const privacy = parsePrivacy(jar.get(PRIVACY_KEY)?.value) return ( <html lang="en" suppressHydrationWarning className={cn(GeistSans.variable, GeistMono.variable, appearance.mode === "dark" && "dark")} {...appearanceAttributes(appearance)} data-privacy={privacy ? "on" : "off"} >
/** Any unknown URL (and notFound() in a page) lands here, inside the shell's inset panel (§12.23). */export default async function NotFound() { const jar = await cookies() return ( <AppShell navCollapsed={parseNavState(jar.get(NAV_STATE_KEY)?.value)}> <NotFoundPage /> </AppShell> )}
/** An id that isn't an invoice number is a 404 — checked outside the loading boundary, so the status is 404 (lesson 7.1). */export default async function InvoiceLayout({ children, params }: Props) { const { id } = await params const number = Number(decodeURIComponent(id).replace(/^#/, "")) if (!Number.isInteger(number) || number <= 0) notFound() return children}
/* brand (the Meridian preset; other presets override this group only) */--primary: oklch(0.53 0.2 266); /* #355edd (white text 5.38:1) */--primary-foreground: oklch(0.99 0.005 266);--primary-soft: oklch(0.965 0.022 266); /* #eef4ff */--primary-soft-foreground: oklch(0.45 0.19 266); /* #2346bc */--ring: oklch(0.62 0.19 266); /* #507cf7 */--hero-tint: oklch(0.6 0.2 295); /* violet, the aurora's top-right glow (§6.2) *//* measured: at the brief's 14% the hero's top-left corner takes muted labels to 4.04–4.33:1 (by preset); 8% keeps ≥4.55:1. Dark mode keeps 14%. */--hero-strength: 8%;/* measured: the brief's 60% puts big figures' symbol and cents at 4.32–4.43:1 on the hero; 65% gives ≥5.05:1 */--money-minor-opacity: 0.65;
/** A background, either a token or `top over bottom` (a translucent layer composited on an opaque surface). */function surface(vars, spec) { const [top, bottom] = spec.split(" over ") if (!bottom) return color(vars, top) const t = color(vars, top) const b = surface(vars, bottom) return { r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 }}
for (const route of routes) { for (const mode of modes) { for (const viewport of widths) { test(`${route} renders in ${mode} at ${viewport.width}px`, async ({ page, context, baseURL }) => { // the kit is the heaviest page by far (13 charts, two tables, the whole ledger on the client) if (route === "/dev/kit") test.slow() const errors = collectErrors(page) await setAppearance(context, baseURL!, { mode }) await page.emulateMedia({ colorScheme: mode }) await page.setViewportSize(viewport) await page.goto(route, { waitUntil: "networkidle" }) await expect(page.locator("h1")).toBeVisible() const shellRoute = shellRoutes.find((candidate) => candidate.path === route) if (shellRoute) { // inside the shell: its h1, the breadcrumb ending in the same page, the header and the footer
export function DataTable<TData>(props: DataTableProps<TData>) { "use no memo" // the table instance is mutable; keep it uncompiled if the compiler is ever enabled // The lint warns even with the directive (react-hooks 7.1): the opt-out is deliberate. // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ … })
) : ( // A full-width fill slid left by what is missing, inside a clipping track: the change animates `transform` // (compositor only, no layout or paint per frame on live pages) and the fill keeps its rounded end. <span className="absolute inset-0 overflow-hidden rounded-full"> <span className={cn("absolute inset-0 rounded-full transition-transform duration-(--dur-slow) ease-out", fill)} style={{ transform: `translateX(${width - 100}%)` }} />
test("links prefetch on intent, not on sight", async ({ page }) => { const rsc: string[] = [] page.on("request", (request) => { if (request.headers()["rsc"] === "1" || request.url().includes("_rsc=")) rsc.push(request.url()) }) await pageReady(page, "/dashboard/finance") await page.waitForTimeout(1500) // the 20-odd visible sidebar and card links no longer fetch their routes on load expect(rsc.length, rsc.join("\n")).toBeLessThanOrEqual(2) const before = rsc.length await page.locator('[data-slot="sidebar"] a[href^="/dashboard/cash-flow"]').hover() await expect.poll(() => rsc.length).toBeGreaterThan(before) await page.locator('[data-slot="sidebar"] a[href^="/dashboard/cash-flow"]').click() await expect(page.locator("h1")).toHaveText("Cash flow")})
// before: import Link from "next/link" — every link in view prefetches its (dynamic) routeexport default function IntentLink({ prefetch, onMouseEnter, onFocus, onTouchStart, ...props }: React.ComponentProps<typeof NextLink>) { const [intent, setIntent] = React.useState(false) return ( <NextLink {...props} prefetch={prefetch === false ? false : intent ? (prefetch ?? null) : false} onMouseEnter={(event) => { setIntent(true); onMouseEnter?.(event) }} onFocus={(event) => { setIntent(true); onFocus?.(event) }} onTouchStart={(event) => { setIntent(true); onTouchStart?.(event) }} /> )}
// before: every even tick drawn centred on its point; the last label hangs past the plot<XAxis dataKey="label" ticks={evenTicks(data, maxXTicks)} interval={maxXTicks ? 0 : "preserveStartEnd"} />// after: the same ticks, and Recharts slides the edge labels inside<XAxis dataKey="label" ticks={evenTicks(data, maxXTicks)} interval="preserveStartEnd" />
// src/app/(app)/settings/[[...tab]]/layout.tsx — outside the route's loading.tsx boundaryexport default async function SettingsLayout({ children, params }: { children: React.ReactNode; params: Promise<{ tab?: string[] }> }) { const { tab } = await params if ((tab && tab.length > 1) || !settingsTabs.some((t) => t.slug === (tab?.[0] ?? "general"))) notFound() return children}// page.tsx: the same check there came after the 200 (loading.tsx streams first)
const rules = [ // Only the lengths a CSS hex colour can have (3, 4, 6, 8). Four plain digits are a record number, not a colour: // "Invoice #5099" would otherwise read as #RGBA. Every other all-digit form (#000, #333333) still fails. { name: "hex colour", pattern: /#(?!\d{4}\b)(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g }, { name: "colour function", pattern: /\b(?:oklch|oklab|lch|lab|rgba?|hsla?|hwb)\(/g }, { name: "Tailwind palette class", pattern: new RegExp(`(?<![\\w-])(?:${colorUtilities})-(?:${palette})-\\d{2,3}\\b`, "g"), }, { name: "Tailwind palette class", pattern: new RegExp(`(?<![\\w-])(?:${colorUtilities})-(?:white|black)\\b`, "g") },]
// before — still centred on a bottom drawer<DrawerHeader className="border-b text-left">// after<DrawerHeader className="border-b px-5 text-left group-data-[vaul-drawer-direction=bottom]/drawer-content:text-left">
// app-footer.tsx — before<footer className="mt-auto px-4 lg:px-6"> <nav aria-label="Legal and support" className="flex items-center gap-4 sm:ml-auto">// after: the footer is a size container; the links keep the button's room only where the row meets the edge<footer className="@container mt-auto px-4 lg:px-6"> <nav aria-label="Legal and support" className="mr-[max(0px,calc(3.75rem-(100cqw-100%)/2))] flex items-center gap-4 sm:ml-auto">
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment