# Setting up and shipping a Next.js 16 template: 17 traps with shadcn/ui, ESLint 10, pnpm 12 and git

> 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.

Tips and fixes, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Meridian — Finance Dashboard Template for Next.js & shadcn/ui (Cash Flow, Live Investments, Invoicing)](https://upfork.dev/products/nextjs-finance-dashboard-template) (Landing page, Free). Published 2026-10-30. 21 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Meridian is an enterprise finance dashboard template for Next.js and shadcn/ui: 22 screens in light and dark, every figure computed from one seeded book ([meridian.upfork.dev](https://meridian.upfork.dev)). It was scaffolded on 28 October 2026 with the latest stable version of every package and shipped on 30 October as a release ZIP made with `git archive`. Seventeen things on that road didn't behave as expected: version ranges that no longer fit, CLIs that answer prompts for you, a Next.js that writes into your project, and a release ZIP that once came out empty and once carried CRLFs git wouldn't convert.

What you'll learn here: how to set up Next.js 16.4 with TypeScript, ESLint 10, Tailwind 4, pnpm 12 and shadcn/ui 4.23 from a script, and how to make and check a release ZIP from a monorepo, each with the exact error and a fix you can reuse. How the dashboard itself works is in the [main article](https://upfork.dev/articles/how-to-build-a-finance-dashboard-where-every-number-agrees-one-seeded-book-l).

What Meridian ended up on:

| Package | Version |
| --- | --- |
| next / react | 16.4.3 / 19.3.1 |
| typescript | ~6.0.3 |
| tailwindcss | 4.3.3 |
| shadcn (CLI) / radix-ui | 4.23.0, style new-york / 1.8.1 |
| eslint / eslint-config-next | 10.13.0 / 16.4.3 |
| eslint-plugin-tailwindcss | 4.4.0 |
| cn | 0.5.0 |
| pnpm / Node | 12.8.1 / 22.23.1 |

## TypeScript, ESLint and pnpm

Three of the newest releases didn't fit together, and two tools changed their defaults.

> **Lesson: typescript@latest is 7.0 (the native port), but typescript-eslint 8.72 accepts only TypeScript below 6.1** (Newer than what I knew; learned by the AI (Claude Opus 5.5); typescript@6.0.3, typescript-eslint@8.72.1, eslint-config-next@16.4.3)
> **Rule:** If `pnpm view typescript version` says 7.x and the project lints with typescript-eslint 8 (eslint-config-next), install typescript ~6.0 instead: typescript-eslint peers <6.1.0.
> **Snippet:**
> ```json
> // package.json, devDependencies
> - "typescript": "^7.0.2"
> + "typescript": "~6.0.3"
> ```
> **Check:** `pnpm typecheck` (tsc --noEmit) exit 0; `pnpm lint` exit 0; `pnpm build` runs "Running TypeScript … Finished TypeScript in 9.4s"; `pnpm peers check` → "No peer dependency issues found".
> **Applies to:** typescript@>=7 <8, typescript-eslint@>=8.72 <9, eslint-config-next@>=16.4 <17
> **I assumed:** "latest stable" for TypeScript means the `latest` dist-tag, as for every other package in the brief.
> **What happened:** `pnpm view typescript version` → 7.0.2 (published 2026-07-08). `pnpm view typescript-eslint peerDependencies` → `"typescript": ">=4.8.4 <6.1.0"`, and eslint-config-next 16.4.3 depends on `typescript-eslint ^8.56.0`.
> **Why:** TypeScript 7 is the Go rewrite; typescript-eslint drives the type checker through the JavaScript compiler API of the 5.x/6.x line.
> **The fix:** `"typescript": "~6.0.3"` (the newest 6.0 release, 2026-04-16) in devDependencies.
> **Evidence:** install summary `+ typescript 6.0.3`; peer range quoted above from the registry on 2026-10-28.

> **Lesson: ESLint 10 runs every eslint-config-next 16.4 rule, though three bundled plugins' peer ranges stop at ESLint 9** (Not in the docs; learned by the AI (Claude Opus 5.5); eslint@10.13.0, eslint-config-next@16.4.3, eslint-plugin-react@7.37.5, eslint-plugin-import@2.32.0, eslint-plugin-jsx-a11y@6.10.2, eslint-plugin-react-hooks@7.1.1, eslint-plugin-tailwindcss@4.4.0)
> **Rule:** If pnpm warns "deprecated eslint@9" and eslint-config-next's plugins peer-require ESLint ≤9, use ESLint 10 and add peerDependencyRules.allowedVersions for those plugins: their rules work.
> **Snippet:**
> ```yaml
> # pnpm-workspace.yaml
> peerDependencyRules:
>   allowedVersions:
>     eslint-plugin-import>eslint: "10"
>     eslint-plugin-jsx-a11y>eslint: "10"
>     eslint-plugin-react>eslint: "10"
> ```
> **Check:** `pnpm peers check` → `No peer dependency issues found`; `pnpm lint` exit 0 on the project; the probe file (deleted afterwards) reported the 10 problems listed below.
> **Applies to:** eslint@>=10 <11, eslint-config-next@>=16.4 <17
> **I assumed:** eslint-config-next needs ESLint 9, because eslint-plugin-react (`^9.7`), eslint-plugin-import (`^9`) and eslint-plugin-jsx-a11y (`^9`) say so in their peer ranges.
> **What happened:** installing eslint@9.39.5 printed `[WARN] deprecated eslint@9.39.5. 10.13.0 is not deprecated, outside the range you declared.` After `pnpm add -D eslint@^10.13.0`: `[WARN] Issues with peer dependencies found` (unmet peer eslint for those three), but `eslint` ran, and a probe file tripped react-hooks/rules-of-hooks, react/jsx-key, react/display-name, react/no-unescaped-entities, jsx-a11y/alt-text, import/no-anonymous-default-export, @next/next/no-img-element, tailwindcss/classnames-order and no-console — `✖ 10 problems (5 errors, 5 warnings)`.
> **Why:** the plugins' rules don't use the context methods ESLint 10 removed; only their declared peer ranges lag.
> **The fix:** `"eslint": "^10.13.0"` and, in pnpm-workspace.yaml, `peerDependencyRules.allowedVersions` for `eslint-plugin-import>eslint`, `eslint-plugin-jsx-a11y>eslint`, `eslint-plugin-react>eslint` = "10".
> **Evidence:** install and lint output, 2026-10-28.

> **Lesson: eslint-plugin-tailwindcss 4 has no configs['flat/recommended']: ESLint crashes reading 'basePath'** (Newer than what I knew; learned by the AI (Claude Opus 5.5); eslint-plugin-tailwindcss@4.4.0, eslint@10.13.0, tailwindcss@4.3.3)
> **Rule:** If ESLint crashes "Cannot use 'in' operator to search for 'basePath' in undefined" in defineConfig, an extends entry is undefined: eslint-plugin-tailwindcss 4 exports only configs.recommended.
> **Snippet:**
> ```js
> // eslint.config.mjs
> {
>   files: ["src/**/*.{ts,tsx}"],
>   ignores: ["src/components/ui/**"],
> - extends: [tailwind.configs["flat/recommended"]],
> + extends: [tailwind.configs.recommended],
>   settings: { tailwindcss: { cssConfigPath: "./src/app/globals.css" } },
> }
> ```
> **Check:** `pnpm lint` then ran and reported `No need for arbitrary 'max-w-[1440px]', use 'max-w-360' instead` (it reads the v4 theme), 0 problems after the fix.
> **Applies to:** eslint-plugin-tailwindcss@>=4 <5
> **I assumed:** the flat config is `configs["flat/recommended"]`, as in eslint-plugin-tailwindcss 3.x.
> **What happened:** `pnpm lint` → `TypeError: Cannot use 'in' operator to search for 'basePath' in undefined at processExtends (@eslint/config-helpers/dist/cjs/index.cjs:457:18)`. `Object.keys(plugin.configs)` → `['recommended']`; that object is already a flat config. The README's own example hedges with `configs["flat/recommended"] || configs.recommended`.
> **Why:** 4.x (the Tailwind v4 rewrite) dropped the legacy eslintrc presets, so the flat preset took the plain name.
> **The fix:** `extends: [tailwind.configs.recommended]` plus the mandatory `settings.tailwindcss.cssConfigPath: "./src/app/globals.css"`.
> **Evidence:** stack trace above; lint output after the change (checked on ESLint 9.39.5, and again on 10.13.0).

> **Lesson: pnpm 12 no longer refuses a too-new release under a caret floor: it adds the exclusion to pnpm-workspace.yaml itself** (Newer than what I knew; learned by the AI (Claude Opus 5.5); pnpm@12.8.1, @hookform/resolvers@6.0.2, lucide-react@1.63.0, motion@14.7.1)
> **Rule:** If pnpm-workspace.yaml gains minimumReleaseAgeExclude entries after `pnpm install`, pnpm 12 wrote them for versions under a day old; keep them, or set minimumReleaseAgeStrict to be asked first.
> **Snippet:**
> ```yaml
> # pnpm-workspace.yaml, after `pnpm install` (written by pnpm)
> minimumReleaseAgeExclude:
>   - '@hookform/resolvers@6.0.2'
>   - framer-motion@14.7.1
>   - lucide-react@1.63.0
>   - motion-dom@14.7.1
>   - motion@14.7.1
> ```
> **Check:** `pnpm install` → `Done in 17m 34.5s using pnpm v12.8.1`, exit 0, `+ @hookform/resolvers 6.0.2`, `+ lucide-react 1.63.0`, `+ motion 14.7.1`; pnpm-workspace.yaml lists the 5 entries.
> **Applies to:** other: pnpm@>=12 <13 with its default minimumReleaseAge
> **I assumed:** from UpFork lesson L-ZWCJ, pnpm refuses a release under a day old, and you pin the version before it or add an exclusion by hand.
> **What happened:** the install succeeded and printed `Added 5 entries to minimumReleaseAgeExclude in pnpm-workspace.yaml (set minimumReleaseAgeStrict to true to gate these updates with a prompt):` — @hookform/resolvers@6.0.2, framer-motion@14.7.1, lucide-react@1.63.0, motion-dom@14.7.1, motion@14.7.1 — and rewrote the file.
> **Why:** likely because each caret range's floor was the fresh version itself (`^6.0.2` published 2026-10-28T07:01Z, `^1.63.0` 2026-10-28T06:10Z, `^14.7.1` 2026-10-27T11:42Z), so no older version could satisfy it; with minimumReleaseAgeStrict off, pnpm 12 lets them through and records the exception. This extends L-ZWCJ, where pnpm refused a Next.js release under a day old: when the range's floor is the new release itself, pnpm 12.8 doesn't refuse it, it adds the exclusion and rewrites pnpm-workspace.yaml.
> **The fix:** kept the entries (the brief asks for the latest stable versions). They can be deleted once those versions are older than the release-age window.
> **Evidence:** install log, 2026-10-28; publish times from `pnpm view <pkg> time --json`. Two days later the five entries were removed and `pnpm install --frozen-lockfile` passed ("Lockfile passes supply-chain policies (782 entries …)"), leaving the file as it was.

> **Lesson: `next dev` 16.4 writes an AGENTS.md into the project root on start; a template folder picks it up untracked** (Newer than what I knew; learned by the AI (Claude Opus 5.5); next@16.4.3)
> **Rule:** If `git status` shows an untracked AGENTS.md after `next dev` (log: "Generated AGENTS.md for AI agents"), set `agentRules: false` in next.config and delete the file, unless you want it shipped.
> **Snippet:**
> ```ts
> // next.config.ts
> const nextConfig: NextConfig = {
>   devIndicators: false,
> + // `next dev` 16.4 otherwise writes an AGENTS.md into the project root on every start
> + agentRules: false,
> }
> ```
> **Check:** `pnpm dev` with the setting: the log has no "Generated AGENTS.md" line, `curl /transactions` → 200, `ls AGENTS.md` → "No such file or directory"; `pnpm typecheck` exit 0 (the key is typed); `git status --short meridian` shows no AGENTS.md.
> **Applies to:** next@>=16.4 <17
> **I assumed:** `next dev` only writes inside `.next/`; the project folder stays as committed.
> **What happened:** the dev log printed "✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable." and `git status --short meridian` showed `?? meridian/AGENTS.md` (679 bytes, a "This is NOT the Next.js you know" block between `<!-- BEGIN:nextjs-agent-rules -->` markers). The block says `next dev` re-adds it if it is removed from a diff.
> **Why:** Next.js 16.4 generates agent rules (node_modules/next/dist/server/lib/generate-agent-files.js) on dev start; the option is `agentRules?: boolean` in NextConfig (config-schema.js accepts `agentRules: z.boolean().optional()`).
> **The fix:** `agentRules: false` in next.config.ts (with a comment), the generated file deleted.
> **Evidence:** dev log line above; config-shared.d.ts:1753 `agentRules?: boolean;`; `git status` before/after.

## shadcn/ui 4.23, run from a script

shadcn's CLI 4.23 sets up projects from presets, so Meridian's `components.json` (style new-york, Radix, neutral) was written by hand, and every component was added with `pnpm dlx shadcn@4.23.0 add <names> --yes`. Primitives edited for Meridian carry a comment starting `Meridian:`, so a buyer re-adding one with `--overwrite` can see what they would undo. Chart, form, checkbox, select, table and five more were written from the registry's JSON instead of the CLI, because `chart.json` pins recharts@3.8.0 and the project has 3.10.1.

> **Lesson: `shadcn init` 4.23 asks for a preset (Nova, Vega, …) and never offers the new-york style** (Newer than what I knew; learned by the AI (Claude Opus 5.5); shadcn@4.23.0, next@16.4.3, tailwindcss@4.3.3)
> **Rule:** If `shadcn init` asks "Which preset would you like to use?" but you need style new-york, write components.json with "style": "new-york" yourself and run `shadcn add index --yes --overwrite`.
> **Snippet:**
> ```json
> {
>   "$schema": "https://ui.shadcn.com/schema.json",
>   "style": "new-york",
>   "rsc": true,
>   "tsx": true,
>   "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
>   "iconLibrary": "lucide",
>   "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
> }
> ```
> ```bash
> pnpm dlx shadcn@4.23.0 add index --yes --overwrite
> ```
> **Check:** `pnpm dlx shadcn@4.23.0 add button` then creates src/components/ui/button.tsx from new-york-v4; `pnpm build` → "✓ Compiled successfully"; the page renders with the neutral tokens.
> **Applies to:** shadcn@>=4.23 <5
> **I assumed:** init prompts for a style (New York) and a base color, as in shadcn 2.x/3.x, and `-b radix` picks Radix primitives.
> **What happened:** `pnpm dlx shadcn@4.23.0 init -b radix --no-monorepo` stopped at `Which preset would you like to use?` — Nova (Lucide / Geist), Vega, Maia, Lyra, Mira, Luma, Sera, Rhea, Custom. Each preset fixes style, font and icon library (Vega = "Lucide / Inter", style "vega"). With a components.json already present, init only asks `A components.json file already exists. Would you like to overwrite it?`.
> **Why:** init in 4.x builds a project from a preset; the new-york style still lives in the registry (`styles/new-york-v4`) and is the CLI's fallback style for `add`.
> **The fix:** hand-wrote components.json (style new-york, rsc, tsx, baseColor neutral, cssVariables true, iconLibrary lucide, the usual aliases), then `pnpm dlx shadcn@4.23.0 add index --yes --overwrite`. It installed the style's dependencies (class-variance-authority, cn, lucide-react, radix-ui, tw-animate-css, shadcn) and wrote globals.css: `@import "tw-animate-css"`, `@import "shadcn/tailwind.css"`, `@custom-variant dark`, `@theme inline`, the neutral `:root`/`.dark` variables and the base layer.
> **Evidence:** CLI prompt text above; globals.css grew from 1 line to 126.

> **Lesson: shadcn's registry components now import `cn` from the npm package “cn”, not from @/lib/utils** (Newer than what I knew; learned by the AI (Claude Opus 5.5); shadcn@4.23.0, cn@0.5.0)
> **Rule:** If a freshly added shadcn component imports { cn } from "cn", that is the registry now: make lib/utils.ts `export { cn } from "cn"` so your code and shadcn's use one function.
> **Snippet:**
> ```ts
> // src/lib/utils.ts
> - import { clsx, type ClassValue } from "clsx"
> - import { twMerge } from "tailwind-merge"
> - export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
> + export { cn } from "cn"
> ```
> **Check:** `head -4 src/components/ui/button.tsx` shows `import { cn } from "cn"`; `pnpm typecheck`, `pnpm lint`, `pnpm build` exit 0.
> **Applies to:** shadcn@>=4.22 <5, cn@>=0.5 <1
> **I assumed:** shadcn components import `cn` from "@/lib/utils", a local clsx + tailwind-merge helper the CLI writes once.
> **What happened:** `shadcn add button` added `"cn": "^0.5.0"` to dependencies and wrote `import { cn } from "cn"` — also after I had created src/lib/utils.ts with the clsx/twMerge helper. The registry item https://ui.shadcn.com/r/styles/new-york-v4/button.json lists `dependencies: ["cn", "radix-ui"]`, and utils.json's file is `export { cn } from "cn"`.
> **Why:** cn (repo shadcn-ui/cn) describes itself as "Fast, small, compiled class-name merging for Tailwind CSS. Drop-in replacement for clsx + tailwind-merge." The CLI also carries a migration from clsx/tailwind-merge to cn. (Another UpFork product added on 2026-10-25 still got `@/lib/utils` imports, so the switch is recent.)
> **The fix:** src/lib/utils.ts = `export { cn } from "cn"` (the registry's own file); removed clsx and tailwind-merge from dependencies.
> **Evidence:** registry JSON fetched 2026-10-28; `pnpm remove clsx tailwind-merge` → `- clsx 2.1.1`, `- tailwind-merge 3.7.0`.

> **Lesson: A scripted `shadcn add` answers its overwrite prompt “no”, stops there, and still prints Done with exit 0** (The tools and the machine; learned by the AI (Claude Opus 5.5); shadcn@4.23.0, pnpm@12.8.1)
> **Rule:** If `shadcn add` run from a script prints "Would you like to overwrite? (y/N)" then Done, check every file: closed stdin answered no and the run stopped. Back up edited files, rerun with --overwrite.
> **Snippet:**
> ```bash
> # before: exits 0, but a declined overwrite ends the run
> pnpm dlx shadcn@4.23.0 add index --yes < /dev/null            # globals.css untouched
> # after: keep your edited primitive aside, overwrite, put it back
> cp src/components/ui/button.tsx /tmp/button.keep.tsx
> pnpm dlx shadcn@4.23.0 add sidebar calendar --yes --overwrite < /dev/null
> cp /tmp/button.keep.tsx src/components/ui/button.tsx
> ```
> **Check:** the second runs printed `✔ Created 3 files: input.tsx, skeleton.tsx, command.tsx` and `✔ Created 3 files: use-mobile.ts, calendar.tsx, sidebar.tsx … ℹ Updated 1 file: button.tsx`; `git diff --stat -- src/components/ui/button.tsx` empty after the restore; `pnpm typecheck` exit 0.
> **Applies to:** shadcn@>=4.23 <5, other: pnpm dlx with no TTY
> **I assumed:** `--yes` answers every prompt, and a declined overwrite skips only that one file while the rest are still written.
> **What happened:** with one file: `pnpm dlx shadcn@4.23.0 add index --yes < /dev/null` installed the dependencies, printed `? The file utils.ts already exists. Would you like to overwrite? » (y/N)` followed by `Done in 40.6s using pnpm v12.8.1`, exit 0 — and src/app/globals.css was still the single line `@import "tailwindcss";`. With many: `pnpm dlx shadcn@4.23.0 add sidebar dropdown-menu popover command dialog breadcrumb tabs avatar collapsible drawer scroll-area sonner textarea kbd separator tooltip sheet --yes < /dev/null` printed `? The file button.tsx already exists. Would you like to overwrite? » (y/N)` (asked for sidebar's dependency `button`, which an earlier phase had edited) and `Done in 42.7s`, exit 0. 15 files were created; command.tsx, input.tsx, skeleton.tsx, sidebar.tsx and hooks/use-mobile.ts were not, and nothing said so.
> **Why:** `--yes` only skips the confirmation prompt; the per-file overwrite question is separate, takes "no" when stdin is closed, and ends the run: the files still queued behind it are dropped.
> **The fix:** `add index --yes --overwrite` wrote globals.css (`✔ Updating src\app\globals.css`); `add command input skeleton --yes` (no conflict) wrote the 3 missing files at once; for the sidebar, button.tsx was copied aside, `add sidebar calendar --yes --overwrite` run, and the file copied back.
> **Evidence:** `ls src/components/ui` after the first multi-component run (15 files, no sidebar/command) and the two `add index` outputs, 2026-10-28.

> **Lesson: A shadcn file copied from the registry JSON fails tsc: its imports use @/registry/new-york-v4/ui, which the CLI rewrites** (The tools and the machine; learned by the AI (Claude Opus 5.5); shadcn@4.23.0, recharts@3.10.1, typescript@6.0.3, next@16.4.3)
> **Rule:** If you write a shadcn component from the registry JSON's files[].content (to skip the CLI), replace every "@/registry/new-york-v4/ui/" import with your ui alias, or tsc fails TS2307.
> **Snippet:**
> ```bash
> curl -s https://ui.shadcn.com/r/styles/new-york-v4/alert-dialog.json -o alert-dialog.json
> node -e "const j=require('./alert-dialog.json');require('fs').writeFileSync('src/components/ui/alert-dialog.tsx', j.files[0].content)"
> sed -i 's#@/registry/new-york-v4/ui/#@/components/ui/#g' src/components/ui/alert-dialog.tsx   # what the CLI would have done
> ```
> **Check:** `pnpm exec tsc --noEmit` → no output, exit 0; `pnpm build` → "✓ Compiled successfully"; `grep -rn "@/registry" src` → nothing.
> **Applies to:** shadcn@>=4.23 <5
> **I assumed:** a registry item's `content` is the file the CLI would write, so saving it under components/ui is the same as `shadcn add`.
> **What happened:** I wrote chart, form, checkbox, select, table, alert-dialog, switch, label, radio-group and card from https://ui.shadcn.com/r/styles/new-york-v4/<name>.json, because chart.json lists `"dependencies": ["cn", "recharts@3.8.0", "lucide-react"]` and `shadcn add chart` would have installed that pinned version over the project's recharts 3.10.1. `pnpm exec tsc --noEmit` then failed: `src/components/ui/alert-dialog.tsx(7,24): error TS2307: Cannot find module '@/registry/new-york-v4/ui/button' or its corresponding type declarations.` and the same for `src/components/ui/form.tsx(17,23)` with `'@/registry/new-york-v4/ui/label'`.
> **Why:** the registry stores files with the registry's own import paths; the CLI rewrites them to the aliases in components.json when it installs. Items that depend on other items (alert-dialog → button, form → label) carry those paths.
> **The fix:** `sed -i 's#@/registry/new-york-v4/ui/#@/components/ui/#g'` over the written files (2 imports in 10 files), then the project's usual token edits (`text-negative-text` for `text-destructive`, `bg-overlay` for `bg-black/50`).
> **Evidence:** tsc output above; registry JSON fields as fetched with curl on 2026-10-28 (registry style new-york-v4).

## Shipping the release ZIP

The buyer's ZIP is `git archive` of the committed folder, without the build log, the lessons or the screenshots: `git -c core.autocrlf=false -c core.eol=lf archive --format=zip --prefix=meridian/ -o dist/meridian-1.0.0.zip HEAD:meridian`, run from the repository root, with `.gitattributes` keeping `docs/`, `qa/` and the listing scripts out. The 1.0.0 ZIP holds 337 files with 0 CRs and 0 NUL bytes, and an unzipped copy passes `pnpm install --frozen-lockfile` and `pnpm build`. Five lessons came out of getting there.

> **Lesson: The repo root's unanchored `build/` ignore rule swallowed a source folder: src/lib/data/build was never committed** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **Rule:** If a source folder is named build/ (dist/, out/) and the repo root ignores `build/` unanchored, run `git ls-files <folder>`: empty means never committed; un-ignore it with `!/path/build/`.
> **Snippet:**
> ```gitignore
> # meridian/.gitignore
> /build
> + # The repo root ignores every folder named build/; this one is source (the seeded data builders, §13)
> + !/src/lib/data/build/
> ```
> **Check:** `git check-ignore -v meridian/src/lib/data/build/investments.ts` exits 1; `find meridian/src meridian/tests meridian/scripts meridian/public -type f | git check-ignore --stdin` prints nothing; and a `git archive` of the commit, built outside the repo, passes `pnpm install --frozen-lockfile` and `pnpm build`.
> **Applies to:** other: git ignore rules in a monorepo (git@>=2)
> **I assumed:** `git status` lists every new source file, so anything the phases built was either committed or visibly untracked.
> **What happened:** `git check-ignore -v meridian/src/lib/data/build/investments.ts` → `.gitignore:6:build/ meridian/src/lib/data/build/investments.ts`, and `git ls-files meridian/src/lib/data/build` printed nothing: the eight seeded data builders written in phase 3 were on disk only. Every phase, and the review of every phase, was checked on the working tree, so for three phases origin/main lacked them while every check was green; a fresh clone or a `git archive` doesn't build.
> **Why:** the repo-wide .gitignore (`build/`, meant for build output in every product) has no leading slash, so it matches a directory named build at any depth; ignored folders never show in `git status`.
> **The fix:** meridian/.gitignore gained `!/src/lib/data/build/` (a deeper .gitignore's negation wins for its subtree) and the files were committed. And a release is checked the way the buyer gets it: the committed tree exported with `git archive` (or cloned) outside the repo, then a frozen install and a build there.
> **Evidence:** `git ls-files` empty on origin/main b2f9d7f; the clean-copy build after 9929efc: 336 files, frozen install and build green (git 2.52.0).

> **Lesson: git archive of a subfolder kept an empty docs/ and the tracked qa/README.md: export-ignore the folders themselves** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **Rule:** If `git archive HEAD:<dir>` still lists an empty `docs/` after its files got export-ignore (or a tracked qa/README.md), put `export-ignore` on the folder: `docs export-ignore`.
> **Snippet:**
> ```gitattributes
> # before: the files go, an empty docs/ stays; qa/README.md ships
> docs/BRIEF.md export-ignore
> docs/PROGRESS.md export-ignore
> # after: the folders are skipped whole
> docs export-ignore
> qa export-ignore
> ```
> **Check:** `git archive --worktree-attributes HEAD meridian | tar -t | grep -E "^meridian/(qa|docs)"` prints nothing before the commit; after it, `git archive HEAD:meridian | tar -t` lists no docs/ or qa/.
> **Applies to:** other: git archive with .gitattributes (git@>=2)
> **I assumed:** marking BRIEF.md, PROGRESS.md, LESSONS.md and palette-preview.png export-ignore kept docs/ out of the buyer's ZIP, and qa/ (gitignored but for its README) wasn't in it.
> **What happened:** `git archive HEAD:meridian | tar -t` listed `docs/` (an empty folder entry), `qa/` and `qa/README.md`. Testing a fix with `git archive --worktree-attributes HEAD:meridian` changed nothing at all: even docs/BRIEF.md came back.
> **Why:** export-ignore on files drops the files but not their folder's tree entry; a folder is skipped whole only when it has the attribute itself. `--worktree-attributes` matches the working tree's .gitattributes against the archive's paths, which for `HEAD:meridian` lack the `meridian/` prefix; test with `git archive --worktree-attributes HEAD meridian` instead.
> **The fix:** meridian/.gitattributes adds `docs export-ignore` and `qa export-ignore` (the file lines stay).
> **Evidence:** `git archive HEAD:meridian` gave 411 entries with `docs/`, `qa/` and `qa/README.md`; the worktree test of the fix gave 408, none of them, 2026-10-30.

> **Lesson: `git archive … HEAD:meridian` run inside meridian/ made a 134-byte ZIP with no files** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **Rule:** If `git archive <rev>:<folder>` gives an empty or tiny archive, run it from the repository root: from a subdirectory git also filters by that subdirectory's path inside the tree you named.
> **Snippet:**
> ```bash
> # before: run inside meridian/ — exit 0, a 134-byte ZIP with no files
> git -c core.autocrlf=false -c core.eol=lf archive --format=zip --prefix=meridian/ -o ../dist/meridian-1.0.0.zip HEAD:meridian
> # after: the same command from the repository root
> git -c core.autocrlf=false -c core.eol=lf archive --format=zip --prefix=meridian/ -o dist/meridian-1.0.0.zip HEAD:meridian
> ```
> **Check:** `unzip -Z1 dist/meridian-1.0.0.zip | grep -v '/$' | wc -l` → 337; clean copy: CR 0, NUL 0, frozen install and build exit 0.
> **Applies to:** other: git archive with a `rev:path` tree-ish run from a subdirectory
> **I assumed:** the recorded command, whose `-o ../dist/…` reads as if run from meridian/, works from there.
> **What happened:** from upfork-products/meridian it exited 0 and wrote dist/meridian-1.0.0.zip at 134 bytes; `unzip -l` → "0  1 file". From the repository root with `-o dist/meridian-1.0.0.zip`: 828,529 bytes, 337 files.
> **Why:** git archive limits itself to the current directory's path; inside the tree `HEAD:meridian` there is no meridian/ folder, so nothing matched.
> **The fix:** run it from the repo root with `-o dist/meridian-1.0.0.zip`; the listing notes now say so.
> **Evidence:** the two archives, 2026-10-30.

> **Lesson: Two raw NUL characters made git store chart-utils.ts as binary, so its CRLFs shipped in the ZIP whatever core.eol said** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **Rule:** If a release ZIP still holds CRs after `git -c core.eol=lf archive`, run `git ls-files --eol`: an `i/-text` source file is stored byte for byte; remove its NULs and commit it as LF.
> **Snippet:**
> ```ts
> // before: a literal U+0000 between the quotes, invisible in most editors
> const signature = keys.join("␀")
> // after
> const signature = keys.join("\u0000")
> ```
> **Check:** the ZIP remade with `git -c core.autocrlf=false -c core.eol=lf archive …`: `find … | xargs -0 cat | tr -cd '\r' | wc -c` → 0, and 0 NULs; typecheck, lint 0 problems, unit 63 passed, frozen install + build from the unzipped copy exit 0.
> **Applies to:** other: Git for Windows git archive and ls-files --eol on TypeScript sources
> **I assumed:** per UpFork lesson L-83P9, archiving with `-c core.eol=lf` would bring the CR count of the unzipped ZIP to 0.
> **What happened:** the 1.0.0 ZIP made with `-c core.autocrlf=false` held 103 CRs, all in src/components/charts/chart-utils.ts. Archives made with core.eol=lf, crlf or unset were byte-identical; the other 334 text files were LF in every one. `git ls-files --eol` showed `i/-text w/-text` for that one file. In Git Bash, `cat -A` printed its lines without `^M` and `grep -l $'\r'` matched 327 files; only `tr -cd '\r' | wc -c` and Python counted right.
> **Why:** `keys.join("␀")` and `signature.split("␀")` were typed with a literal NUL character. A NUL makes git treat the file as binary, so it is never converted on add or archive and its CRLF went in as committed. L-83P9 holds where files are text (Sponsor Bot has `* text=auto`); Meridian has no text attribute, so core.eol never applies to that file.
> **The fix:** the two NULs became `"\u0000"` escapes (the same string at runtime) and the file was committed as LF (d85598e); `git ls-files --eol` → `i/lf w/lf`.
> **Evidence:** four archives (eol unset / lf / crlf / no -c) compared, 2026-10-30; commit d85598e.

> **Lesson: Stopping a background `pnpm exec next start` left node serving the old build on Windows** (The tools and the machine; learned by the AI (Claude Opus 5.5); pnpm@12.8.1, next@16.4.3)
> **Rule:** If you stop a background `pnpm exec next start` from Git Bash on Windows, check the port answers nothing before rebuilding; kill the node child by its command line if it still does.
> **Snippet:**
> ```powershell
> # the shell task is gone, but its node grandchild still serves the old build
> Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '<port>' } |
>   ForEach-Object { Stop-Process -Id $_.ProcessId }
> ```
> **Check:** the process query returned 0 matches; the fresh server served the new build (probe 0 faults).
> **Applies to:** other: Git for Windows bash → pnpm.exe → cmd.exe → node process tree
> **I assumed:** stopping the background shell task ended the server it had started.
> **What happened:** after the stop, the port still answered 307 and `next build` ran under it. `Get-CimInstance Win32_Process` showed sh.exe → pnpm.exe → cmd.exe → node "…\next\dist\bin\next" start -p <port> still alive.
> **Why:** on Windows the shell's termination doesn't reach the grandchild node process.
> **The fix:** `Stop-Process` on the four PIDs whose command line matched the port, then rebuilt and restarted.
> **Evidence:** the 307 and the process list, 2026-10-30 (git 2.52.0.windows.1).

## The listing pictures and the tour

The listing's pictures and its tour video were made by Playwright scripts (`scripts/listing-shots.mjs` and `listing-video.mjs`), so they can be retaken after any change. Two things went wrong on the way.

> **Lesson: A 1440×900 listing shot sliced the next row's text on 11 of 21 routes; scroll to where no edge cuts a line box** (The tools and the machine; learned by the AI (Claude Opus 5.5); @playwright/test@1.64.1, next@16.4.3)
> **Rule:** If a listing screenshot's fold or sticky header runs through a line of text, scroll the panel to the nearest offset where no text line box or control crosses either edge (±4 px), not to a guessed one.
> **Snippet:**
> ```js
> // a line box is cut only when the edge runs well inside it (glyphs sit inside the box)
> const cuts = (edge) => bands.some(([a, b]) => a < edge - 4 && b > edge + 4)
> const clean = (at) => !cuts(at + scroller.clientHeight) && (at === 0 || !cuts(at + header))
> ```
> **Check:** the seven desktop pictures at full size: no text line or control cut at either edge; #5099 back at offset 0 with its title.
> **Applies to:** other: Playwright screenshots of a panel that scrolls under a sticky header
> **I assumed:** as with an earlier template's listing (Wayline), each picture could be the page's first screen as it loads; a card running past the fold reads as "more below".
> **What happened:** Cash flow's first screen cut "$6,800 of $10,000" and the "Pay" button in half; Equities cut the "Geographical distribution" and "Dividends" titles. A scan of every route's first screen found the fold inside text on 11 of 21 (Cash flow, Net worth, Transactions, Monthly analytics, Budget, Equities, F&O, Bonds, Insurance, Invoices, #5099).
> **Why:** three rows of cards fill 900 px on only some pages; where a third row starts at ~750 px its title and first figures sit right on the fold. With a ±3 px margin #5099 failed on a line box that only grazed the fold, and the fit scrolled its title away.
> **The fix:** scripts/listing-shots.mjs `fit()` collects Range.getClientRects() of every text node plus buttons, links, inputs, sliders and small SVGs in the shell's scroller, then tries offsets from 0 (or from a named card just under the header) until neither the fold nor the header's lower edge is more than 4 px inside any box. Sheets use their own scrolling body; Cash flow is framed from its chart card down.
> **Evidence:** fold scan of 21 routes at 1440×900 (scratch script), 2026-10-30; the before/after Cash flow and Equities shots.

> **Lesson: The tour showed “Refreshing…” and grey placeholders: the ready check passed on the old page before the route changed** (My own mistake; learned by the AI (Claude Opus 5.5); @playwright/test@1.64.1, @tanstack/react-query@5.105.0, next@16.4.3)
> **Rule:** If a recorded click or Enter navigates, do it under the cover and wait for the new pathname before checking for skeletons; click a page's toggles once off the record (staleTime Infinity keeps them).
> **Snippet:**
> ```js
> // before: returns at once — the old page is still "ready"
> await page.keyboard.press("Enter")
> await settle(desk, 600)
> // after: the navigation happens under the cover
> await cut(async () => {
>   await page.keyboard.press("Enter")
>   await settle(desk, 600, "/money-management/budget")
> })
> ```
> **Check:** the third cut (92.1 s), every 2 s throughout and at 5 fps around both navigations: no skeleton, no placeholder bars and no "Refreshing…".
> **Applies to:** other: Playwright recording of a Next.js app with TanStack Query placeholderData
> **I assumed:** `settle()` (shell ready, no `[aria-busy]` or `[data-slot=skeleton]` in main) after pressing Enter in the palette meant the Budget page had loaded.
> **What happened:** the first cut (91.8 s) showed, at 57 s, Budget with its "Budgeted vs actual" chart as grey placeholder bars and "Refreshing…" in the header line; Cash flow's Weekly switch and Net worth's 1Y switch each showed "Refreshing…" for about a second.
> **Why:** right after Enter the frame still held the Loans page, which was ready, so the check returned before the client navigation began. The toggles change a query key; placeholderData keeps the old figures on screen while the new key fetches (250–600 ms of simulated latency), and UpdatedMeta says "Refreshing…" meanwhile.
> **The fix:** scripts/listing-video.mjs: `settle(f, ms, "/money-management/budget")` waits for `f.waitForURL(...)` first and also for no "Refreshing…" in main; `warm(f, names)` clicks each toggle behind the cover before the scene, then the default again. The second cut still showed #5099's "Loading the invoice…" skeleton for about half a second: the click had started the route before the cover faded in. Now the tap's ripple plays on the link and the click itself happens inside `cut()` (cover on, recording paused).
> **Evidence:** contact sheets of all three cuts, 2026-10-30.

## What we learned building it

> **Lesson: Claude Code's preview rejects an absolute `cwd` in launch.json for an app outside the project** (The tools and the machine; learned by the AI (Claude Opus 5.5); pnpm@12.8.1, next@16.4.3)
> **Rule:** If preview_start says "cwd must be a relative path within the project root", the app lives outside the session's project: drop cwd and pass the folder to pnpm with "--dir".
> **Snippet:**
> ```json
> - { "name": "meridian", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev"], "cwd": "C:/path/to/meridian", "port": 3000 }
> + { "name": "meridian", "runtimeExecutable": "pnpm", "runtimeArgs": ["--dir", "C:/path/to/meridian", "dev"], "port": 3000 }
> ```
> **Check:** preview_start → `Server started on port …` (the app's dev port); server log `GET / 307`, `GET /dashboard/finance 200`; page title "Finance overview · Meridian"; no console errors.
> **Applies to:** other: Claude Code desktop preview (.claude/launch.json)
> **I assumed:** a launch entry `{ "runtimeExecutable": "pnpm", "runtimeArgs": ["dev"], "cwd": "C:/…/meridian" }` starts the app in that folder.
> **What happened:** `Failed to start server: cwd must be a relative path within the project root.`
> **Why:** the preview only runs commands from inside the session's project; `cwd` cannot leave it.
> **The fix:** no `cwd`, and `"runtimeArgs": ["--dir", "<the app's folder>", "dev"]` (pnpm runs the folder's own `dev` script, so the port stays in package.json; the snippet's 3000 stands for that port).
> **Evidence:** preview log, 2026-10-28.

---

On UpFork: https://upfork.dev/articles/setting-up-and-shipping-a-next-js-16-template-17-traps-with-shadcn-ui-eslint
