Before substantial work:
- Skill check: run
pnpm dlx @tanstack/intent@latest list, or use skills already listed in context. - Skill guidance: if one local skill clearly matches the task, run
pnpm dlx @tanstack/intent@latest load <package>#<skill>and follow the returnedSKILL.md. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
Interactive npm supply-chain risk visualization built with TanStack Start and deployed on Netlify.
- Framework: TanStack Start/React with file-system routes under
src/routes/. - Styling: Tailwind CSS v4 via
@tailwindcss/vite. Semantic colour tokens (paper,ink,muted,rule,levy,ochre,moss) are declared as CSS custom properties insrc/styles.cssand exposed to Tailwind through@theme inline, so they flip with the theme and are readable from SVG presentation attributes. - Fonts: self-hosted in
public/fonts/, no font packages. Archivo (variablewght/wdth) carries the type hierarchy by width; IBM Plex Mono sets every figure. - Colour families: two, and they must not mix. The severity ramp (
moss/ochre/levy) says how bad a scenario is and is driven bybreachProbabilityToneandexpectedBreachTimeTone. The categorical series (series-a/series-b/series-c, plusseries-a-washfor the area fill under the primary curve) identify chart lines and package-field bands, carry no good/bad meaning, and are therefore never red or green. - Package field:
getPackageFieldGeometrylays every modeled package out as one mark in three bands — self, direct deps, transitive deps — returning one SVG path per band. PastFIELD_MAX_MARKSa mark stands for several packages; even then every non-empty band claims at least one mark and the bands always sum tototalMarks. Theaspectargument only changes how many columns the marks are dealt into, so the page (a block) and the OG card (a wide strip) share one geometry. - Build/deploy: Vite with
@netlify/vite-plugin-tanstack-start. - Charts: Custom inline SVG; do not add a charting library for the current charts or visual analogies.
- Bundle analysis: Sonda via the Vite plugin, enabled only for
pnpm run build:analyze; it emits client JavaScript reports under.sonda/. - Server route:
src/routes/api/package-deps.tsexposes a package lookup endpoint backed bysrc/server/packageDeps.ts, npmx, npm registry metadata, Netlify Cache API, and Netlify CDN caching. - Page layout: organised by role, not by width. One grid splits the page into a report column (verdict, ledger, package field, curve) and a control rail (share actions, lookup, sliders). The report therefore stays one contiguous rectangle you could screenshot, and the sliders sit beside the chart they change. Methodology sits full-width below both.
- The control rail clips: it is sticky and scrolls internally (
overflow-y-auto), which also makes itoverflow-x: auto. Its controls sit flush with its edges, so anything that paints outside a box gets cut off. Focus rings need the rail's horizontal padding, and overlays must use the top layer (native popover) rather than absolute positioning. This has caused two bugs; check it when adding anything to the rail. - Document caching: the home route sets
Netlify-CDN-Cache-Controlthrough TanStack Router'sheadersroute option (documentCacheHeadersinsrc/lib/httpCache.ts). The render is a pure function of the search params and Netlify sendsnetlify-vary: query, so each scenario URL caches separately at the edge and shared links keep their own OG tags. - OG image route:
src/routes/api/og.tsgenerates dynamic Open Graph images from URL-backed scenarios.src/lib/ogImage.tsholds the pure model (absolute layout geometry, text wrapping, curve path);src/lib/ogImageView.tsxis the view. It renders through satori, which implements only a subset of CSS — see the conventions below before editing it.
src/
components/
SupplyChainRisk.tsx # Main UI, calculator state, SVG charts
icons.tsx # Inline SVG icons
routes/
__root.tsx # HTML shell, metadata, icons, pre-render theme script
index.tsx # Home route and URL search validation
api/og.ts # Dynamic Open Graph image route
api/package-deps.ts # Server route for package dependency lookup
server/
packageDeps.ts # Upstream package lookup + Cache API
packageDepsModel.ts # Pure name validation, cache keys, response normalizing
ogFonts.ts # TTF subsets inlined into the server bundle for satori
fonts/ # OG-only TTFs; satori cannot read the app's woff2
lib/
riskModel.ts # Pure risk math, defaults, formatting, share copy
riskVisuals.ts # Pure severity tones + three-band package-field geometry
ogImage.ts # Pure OG card model: geometry, text wrapping, curve path
ogImageView.tsx # OG card view (satori)
riskSearch.ts # Search-param parsing + OG_IMAGE_VERSION
httpCache.ts # Cache-Control helpers for the API routes
themeModel.ts # Theme resolution + first-paint bootstrap CSS
styles.css # Tailwind import, design tokens, @font-face, base styles
public/
fonts/ # Self-hosted woff2 + their OFL license texts
favicon.svg # Theme-adaptive icon; favicon.ico holds 16/32/48 rasters
a11y.e2e.ts # Axe scans, both themes, expanded/hovered states
controls.e2e.ts # Control behaviour, desktop + mobile
- TypeScript strict mode.
- Tailwind utility classes for styling; no CSS modules or styled-components.
- SVG charts and visual analogies drawn inline in React.
- Keep calculator math pure in
src/lib/riskModel.ts. - Keep all interactive calculator state shareable through URL search params.
- Every control's readout is also its input (
ValueField): sliders step coarsely, so typing is the only way to reach an exact figure. A held draft keeps the slider from overwriting keystrokes. - The code calls the unit chart the "package field"; user-facing copy calls it the "visualization". Keep it that way; "field" reads as a form input to a programmer.
- Severity colour (
moss/ochre/levy) means good-to-bad and nothing else. Categorical encoding — chart series, package-field bands — uses theseries-*tokens and must never be red or green, so a category is never mistaken for a verdict. - Categorical distinctions must survive without colour. Chart series pair a
series-*stroke with a distinct dash pattern and weight; the package field relies on a three-step luminance ramp (ink/series-b/ink-faint). Three hues that all clear AA on the light ground sit close in greyscale, so colour alone is never sufficient. - Severity is always named in text beside its colour (the assessment chip reads
ASSESSED LOW/MEDIUM/HIGH), per PRODUCT.md's rule against colour-only encoding. - First-paint theme-sensitive visuals must be CSS-driven from the bootstrapped
html.dark/html[data-theme]state. Do not derive initial SVG plot fills, grid strokes, or other visible theme colours from React state after hydration. - Use runtime-appropriate relative import specifiers. Vite/TanStack-only app modules should use extensionless imports, such as
../lib/riskModel. - Keep explicit
.tsextensions in TypeScript loaded directly bynode --test: test files and thesrc/liborsrc/servermodel modules they import. - Do not hand-edit generated
src/routeTree.gen.ts; it may use generated import specifiers that differ from hand-written source style. It also shows as modified whilepnpm run devruns, because the dev generator orders the API routes differently fromvite build; that churn is noise, so revert it rather than committing it. - The OG card renders through satori, not a browser. Flexbox only (no grid, no float), explicit
display: "flex"on containers, literal hex instead of CSS custom properties, and registered font families only. A component that returns an array crashes the render with no error, andundefinedin a style object does the same.<svg>has nopreserveAspectRatio, so scale uniformly. Verify changes by fetching/api/ogand looking at the PNG. - Bump
OG_IMAGE_VERSIONinsrc/lib/riskSearch.tswhenever the OG card's appearance changes. It is theogvcache-buster, and without it social platforms and the CDN keep serving the previous image. - Fonts are vendored, not installed. Add nothing from npm for typography: the app's woff2 live in
public/fonts/, the OG card's TTF subsets insrc/server/fonts/, and both families' OFL texts ship alongside.
Two tiers, and the split decides where a new test goes.
node --test(pnpm run test:unit) covers the pure modules insrc/libandsrc/serveronly: math, formatting, parsing, geometry, cache headers. No DOM, no framework. When logic in a component is pure, extract it here rather than reaching for a component test.- Playwright (
pnpm run test:e2e) covers everything that needs a browser: control behaviour incontrols.e2e.tsand axe scans ina11y.e2e.ts. Both run under adesktopand amobileproject, because the two layouts differ structurally rather than only in width.testMatchglobs*.e2e.ts, so a new file needs no config beyond aknip.config.tsentry.
There are no component tests and adding jsdom is not the answer: it has no layout engine, so it
cannot see the bugs this UI actually has: clipped focus rings, contrast under opacity, or a
popover in the top layer. Prefer extracting pure logic downwards or asserting in Playwright.
Two Playwright gotchas, both of which have produced false results here:
- Wait for hydration before interacting.
page.gotoresolves before React attaches, and a click landing early both fails silently and leavesopenon the DOM for React to trip over. - Do not
fill()an already-focused controlled input; it races React's own value updates and concatenates. Click, select all, and type instead;controls.e2e.tshas a helper.
pnpm run test: unit tests, typecheck, format check, lintpnpm run test:e2e: Playwright behaviour and accessibility, desktop and mobilepnpm run buildpnpm run build:analyzepnpm run knip