Design system: tokens, typography, and test guards - #27
Merged
Conversation
Phases 0-2 of plans/plan-design-system.md. Tokens (app/globals.css, tailwind.config.ts) - Warm-tinted neutrals: paper #fdfdfc, ink #1c1a17. Sub-1% chroma; the reference sites (archstone.app #fafaf9, hanoverpark.com #f8fcfc) all tint their paper, and it is most of what separates them from a default shadcn install. - Evergreen brand ramp, --brand-50..950 at hue 164. Every stop is contrast-verified: 700 takes white text at 7.05:1 and is the CTA fill, 500 clears 5.14:1 on the dark surface. --brand was already declared and wired to themeCssVars but consumed nowhere; it now has a value. - Status tokens: success/warning/info/destructive, each with -foreground and -subtle, each verified >= 4.5:1 as text on paper and as a fill with white text. Amber is a warning, not a brand colour - that separation is the point of introducing an accent at all. - Named type steps (display/title/heading/lede/label/caption/eyebrow) carrying their own line-height, tracking and weight. Tailwind's text-xs..2xl are untouched and stay correct for dense app UI. - Radius pair: 0.25rem controls, 0.5rem cards (rounded-card), matching hemrock.com. This sharpens corners app-wide - one token to revert. - Motion tokens: ease-out-soft, ease-expo. Type (app/layout.tsx, lib/theme.ts) - Inter becomes the default --font-sans, matching the brand guide and hemrock.com. This repo defaulting to the system stack was the single genuine mismatch between the two sites. - Newsreader as --font-display. Variable weight plus an optical-size axis, so one face covers a 68px hero and a 20px report cover. Kept out of FONT_OPTIONS: it is Hemrock's voice, not a per-fund choice. - rampFor() regenerates the ramp from a fund's accent hue, so tints and hairlines follow the fund instead of falling back to evergreen. The fund's chosen value stays the fill and is NOT relocated onto stop 700 - amber pushed to 700 is brown. Marketing (app/(public)) - Hero in the display serif at weight 400 with an optional accent phrase. Large and light reads as expensive; large and bold reads as a dashboard. - Eyebrows above section headings; 1100px container; 640px prose measure; 80-112px section rhythm. - Card textures differentiated by role - features borderless on a hairline shadow, pricing keeps its border so the decision moment carries weight, "why" drops card chrome entirely. - One primary CTA in the header instead of three identical outlines. - CFO callout moves off bg-amber-50, which read as a validation warning. - Optional site_content fields: hero.emphasis, group.eyebrow, stats[]. Docs and tests - DESIGN.md documents the tokens and lists what hemrock.com/brand needs. - CLAUDE.md gains a Styling section - raw palette classes and font-mono on figures are the two mistakes that actually get made. - lib/theme.test.ts pins the generated ramp against the values hardcoded in globals.css so the two cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
The stated reason ("Hemrock's voice, so a fund theme cannot override it")
was wrong. The marketing page is not under a fund theme at all -
themeCssVars is injected in app/(app)/layout.tsx, not app/(public) - so
the hero is protected regardless of what FONT_OPTIONS contains.
The real reason is mechanical: FONT_OPTIONS drives --font-sans, the body
font for the whole app. A serif there would land on every dense financial
table. A per-fund display face is a reasonable feature, but it needs its
own axis writing --font-display.
Also records the figure profiles behind the Inter choice, measured from
the font binaries: Inter and Plus Jakarta Sans are proportional with tnum
available; Hanken Grotesk and Newsreader are permanently tabular (all ten
digits share one advance width), so Hanken Grotesk cannot do proportional
numerals in running prose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Adds FundTheme.displayFont, a second font axis separate from the UI font.
FONT_OPTIONS drives --font-sans (the body font, which lands on every dense
financial table); this drives --font-display only: report covers, letter
mastheads, statement headers. Three curated serifs - Newsreader (default),
Source Serif 4, Libre Caslon Display.
Kept short on purpose: every option has to be embedded as base64 woff2 for
server-rendered PDFs, so each addition has a real per-render cost.
PDF exports
- pdfFontCss(displayFont) embeds only the ONE face the fund selected, always
under the family name 'PDFDisplay', so templates reference ${PDF_DISPLAY}
without knowing which font it resolved to. Emitting all three would add
~76KB of base64 to every document for two faces it never references.
- Report, letter and capital-statement templates now use the fund's face on
headings, at weight 400 rather than 600/700 - the same "large and light"
treatment as the marketing hero.
- Figures move off Roboto Mono to Inter's tabular figures
(font-variant-numeric). 26 cells across the three templates. Same column
alignment, better letterforms, and it stops money looking like code. This
is the same mono-on-numbers issue the app carries, which Phase 3 addresses;
the exports had it too.
- generateLetterPdf did not query fund_settings at all, so it now does.
displayFontOf() validates the key against the curated list before it reaches
a template, so a direct DB write cannot push an arbitrary string into the
font stack.
Word exports are deliberately untouched. DOCX stores a font NAME and renders
with whatever the recipient has installed, so offering a webfont there would
promise something the format cannot deliver - the fund would pick a serif and
their LPs would see a substitution. The settings copy says so.
lib/pdf-fonts.test.ts guards the failure this module exists to prevent: a
face referenced but not embedded renders as Calibri, silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Phase 3, first batch. font-mono and tabular-nums were being used as rival solutions to the same problem: 296 uses of one, 72 of the other, and exactly ONE element carrying both. Near-perfect disjointness is the tell - two conventions for aligning numbers in a column, applied by whoever wrote the page. Column alignment needs tabular FIGURES (font-variant-numeric), not a monospaced face. Inter ships them, so alignment is preserved while keeping the proportional face's letterforms. Mono also made money read as code in a product whose entire subject is money. Converted, in decreasing order of certainty: - 169 cells combining text-right with font-mono - unambiguously numeric columns. 152 in quoted classNames, 17 more in template-literal classNames (a first pass missed those, which is why the count is split). - 22 sites wrapping a money formatter or a date field. - 7 stat values and numeric inputs found by inspection. font-mono: 296 -> 98. tabular-nums: 72 -> 269. What deliberately keeps mono: code blocks and chips, JSON and SQL views, API keys, GA/Fathom IDs, email patterns, model names, account codes, version strings, OTP/MFA inputs - content a machine reads literally. The journal view also keeps it; its ledger listing is account codes as much as amounts, and the monospaced look there is deliberate rather than accidental. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Phase 3, second batch. Colour: 996 raw Tailwind palette classes -> 23, across 105 files. amber/yellow/orange -> warning, red/rose -> destructive, green/emerald -> success, blue/sky/cyan/indigo -> info, and the grey families -> muted / muted-foreground / border. Fills at the 50/100/800/900/950 stops map to the -subtle variants; mid stops map to the solid token. Opacity modifiers are preserved (bg-amber-500/10 -> bg-warning/10). Two passes, because the classes live in two places: className attributes (337 strings) and per-file status->class constant maps (133 string literals). The second group is the "status vocabulary reinvented per file" the plan identified - every emails/review/compliance page had its own. `dark:` twins are dropped where they became redundant: a token already flips per colour-scheme, so `text-amber-600 dark:text-amber-400` is now just `text-warning`. That is most of the line-count reduction. This is the white-labelling fix as much as a cosmetic one. Those 996 classes were precisely what themeCssVars() could not repoint - a fund on an emerald accent still got amber locks and blue beta chips. The 23 left are purple/violet/pink used CATEGORICALLY (relationship types, compliance categories, LP activity kinds), not as status. They need a categorical palette, which is the same open question as --chart-1..5, so they are deliberately untouched rather than forced into a status token. Radius: 215 ad-hoc card surfaces (rounded-lg/md + border + padding) move to rounded-card. components/ui/card.tsx was rounded-xl - a third radius nothing else in the codebase used - and is now rounded-card too, so the shared component and the hand-rolled ones finally agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Phase 3 tail, plus the Phase 4 guardrail. Display face in-app. FundTheme.displayFont previously styled nothing inside the app - font-display appeared only on the marketing page, which fund themes do not reach - so the setting was real but invisible. It now lands on the LP report card, the portal letter title and the portal snapshot title. The portal layout already injects themeCssVars, so a fund's chosen face applies there. While mapping page titles I found the earlier read was wrong. What looked like 11 drifted headings is two deliberate conventions: the app uses text-2xl, and the LP portal (plus its admin preview) consistently uses text-xl, with auth/welcome screens at text-lg. Those are left alone. The one genuine outlier was lp-report-card's font-bold against font-semibold everywhere else, and it becomes a display heading here anyway. Guardrail (lib/design-tokens.test.ts). Fails on new raw palette classes, and on text-right combined with font-mono. Same reasoning as lib/access/route-domains.test.ts: a convention nothing enforces decays, and this one had already decayed to 996 usages before it was migrated. The categorical allowlist carries a reason per file, and a second test fails if an allowlisted file becomes clean, so the list cannot quietly turn into a place to hide new violations. Verified the guard is not vacuous by injecting a violation and watching it fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
…ours Adds --cat-1..8: colour for IDENTITY (which kind of thing), distinct from the status tokens (which state). Raw palette classes now stand at 0, and the guardrail's allowlist is empty. Why compliance needed this. Its categories were borrowing STATUS colours: "SEC Filings" was amber, "State Compliance" red, "Tax Filings" green - so a routine state filing rendered as an error, and three unrelated categories shared amber because there were not enough status colours to go round. Relationship tags had the same disease: "hiring" and "product" were both info and so indistinguishable, and "legal" was destructive. The palette is the dataviz skill's validated reference set, re-validated against THIS app's surfaces (#ffffff light card, #262422 dark card) rather than assumed to carry over. Dark is a selected set of steps for the dark surface, not a flipped light one. Series ceilings were computed, not guessed, and they differ by chart form because they depend on which pairs a reader compares: - adjacent pairs (stacked bars, lines, chips): all 8 slots pass. - all pairs (pie, scatter): only FOUR clear it in both modes, and only two subsets do. The SOI pie used five hues AND cycled them with SLICE[i % SLICE.length]; it now takes the validated subset and folds the tail into "Other". Slots 3/4/5 sit under 3:1 on white, so nothing puts text on them: category chips are neutral with a coloured dot, relationship tags are ink on a 15% tint. Identity never rests on colour alone. LP activity icons went the other way and lost their colour entirely. Each event kind already carries a distinct glyph AND a label, so colour was a third encoding of something unambiguous - and validation showed no three slots clear all-pairs separation in both themes at legible icon contrast without pulling in the red slot, which would have painted "Download" as an error. duplicate_period joins the other review flags on warning: it is a reason of warning severity, and severity legitimately repeats across reasons when the label carries the distinction. --chart-1..5 now alias --cat-1..5 instead of carrying stock shadcn values unrelated to the brand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Phase 3, final items. Figures. Six near-identical MetricBox/Stat components had drifted: values were text-xl in some and text-2xl in others, the label sat above in four and below in two, and only three carried tabular figures. They are now one components/ui/metric.tsx - an eyebrow label over a large tabular-figure value, used in 7 places. This is the shape of the type-hierarchy pass generally. The scale was 91% text-xs/text-sm, and the fix is NOT to make everything bigger - the dense tables are correct at 12/14px. It is to promote the few things that carry meaning. A figure is what the reader came for, so it becomes the largest type on most of these pages. Page width. There are now two named widths and only two: max-w-page (the app-wide cap, applied once on the wrapper in app/(app)/layout.tsx) and max-w-readable for forms and prose. Worth being precise about what changed, because my first attempt was wrong: I added a cap in app-shell believing page content was unbounded. It was not - app/(app)/layout.tsx already capped it with a bare max-w-screen-xl, one level up, which would have made the shell cap inert. Reverted that, and max-w-page is set to the same 1280px the app already used. So this NAMES the existing behaviour rather than changing it; no page gets wider or narrower. What did change is the pages that were each picking their own number - max-w-6xl, max-w-7xl, max-w-screen-xl - across companies, diligence, dashboard and the LP preview. Those now use max-w-page, so routes line up with each other. Card, modal and label max-widths are untouched; this is about page containers only. design-tokens.test.ts gains a third guard for the three container sizes that were being picked at random. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
api/contact. The route was deleted deliberately in 53c565c (the same commit that removed explainer/legal/pricing and re-pointed legal links to hemrock.com), and nothing in the codebase referenced it - no fetch, no form action, no link. The registry entry was the only mention left, so it goes. This is not an OPTIONAL_ROUTES case: that list is for routes whose file is absent in CI but which are still real. The suite is now fully green (680/680). EmptyState. 53 distinct empty states across 44 files had drifted into ~14 treatments, but they were doing two different jobs, so they do not collapse into one component: - Inline notes ("nothing here yet" inside an existing card) stay as plain muted text. They were right; they had only drifted on size and italics. - Panel states (a whole view with nothing in it) now use components/ui/empty-state.tsx: dashed chrome, a balanced sentence at reading measure, and an optional action slot. 14 converted, copy preserved verbatim. The action slot is the point of the exercise. Most of these panels already DESCRIBE the way out in prose - "add a partner above, or import opening balances from the Accounting home page" is a link written as a sentence - while offering no control. The good pattern already existed in letters/ and dashboard-companies/, which ship a real button; it just was not applied consistently. Wiring the rest needs a per-page decision about what the action is, so this commit only makes the slot available. Also de-italicised 12 muted empty-state notes. The 18 remaining italics are deliberate and stay: blockquotes around quoted email text, "(no subject)" and "Unknown" marking a substituted value rather than real content, and "edited" metadata markers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Wires the action slot added with EmptyState. Each of these already DESCRIBED
the way out in prose while offering no control; the prose is trimmed to match
now that a button carries it.
capital accounts -> Import opening balances /funds/{seg}/opening-balances
journal -> Import bank transactions /funds/{seg}/bank
opening balances -> Go to LPs /lps
schedule of inv. -> Open Portfolio /dashboard
statements -> Open journal /funds/{seg}/journal
Investments are recorded on a company, and every company is reachable from
Portfolio, so that is the way in from the schedule of investments - the copy
now says so rather than leaving a dead end.
Fund-scoped links use the existing useFundSeg() helper and render only when a
vehicle is selected, so a link can never point at /funds/null.
Two deliberate non-actions. The journal's search-miss variant offers nothing -
the search box is directly above, and an import button would answer a question
nobody asked. The bank page's "no transactions" keeps its prose because the
importer is already on screen immediately above it.
Left as prose: review and pending-actions ("all clear" is a success state, not
a gap), requests (a non-admin genuinely cannot act), and fund status (an error,
not an empty state).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Measuring first changed the diagnosis, and corrected something I had said earlier: text-xs is NOT mainly a dense-table style here. Only 36 of its 1064 uses are in a <td>. The rest are <p> (354), <div> (180), <span> (147) and <label> (98) - prose and labels, not table data. Two real problems fell out of that, and this fixes both. Section headings had no convention. 124 h2/h3 across five sizes: text-sm (75), text-base (29), text-lg (13), text-xl (3), text-xs (4). The dominant case made a section heading exactly the size of the paragraph under it, so hierarchy rested entirely on font-weight - the ladder went 24px -> 14px -> 14px with the middle rung missing. 79 headings promoted to text-base, and three stray text-xl settled onto text-lg. h2/h3 is now text-base (108) or text-lg (16) and nothing else. text-lg is kept as a real rung, not flattened: it leads whole page regions on pages that have no h1 (usage, updates, import), which is a genuine step above an ordinary section heading. Errors and warnings moved off text-xs to text-sm - 104 messages across 55 files. The one thing a user must be able to read was the smallest type on screen. Explanatory prose at text-xs is deliberately untouched. design-tokens.test.ts gains two guards: an h2/h3 at body size or smaller, and error/warning text below body size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Stops the weekly grouped npm and github-actions PRs. Dependabot SECURITY updates and alerts are repo settings, not driven by this file, and still need turning off under Settings > Code security > Dependabot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF
Three changes from review of the preview build. Centre alignment: hero title and lede, every section heading and its description, and the proof-strip totals. Cards, FAQ rows and the four "why" items stay left-aligned — centring body copy inside a card hurts readability and the request was about titles and subtitles, not everything. Hero CTAs: a row under the lede in descending order of commitment — try the demo, explore features, view the source. Each button is driven by site_content.links, so an absent href drops that button rather than rendering a dead one, and the row disappears entirely if none are set. Adds a `features` key to SiteLinks for the hemrock.com product page; github and demo already existed. Display face: Newsreader -> Source Serif 4 as the app-wide default. Also drawn for screen and variable-weight, but lower contrast and sturdier, and it shares a humanist skeleton with Inter so the pairing reads as chosen. This is the default for report covers, letter mastheads, statement headers and PDFs too, not just marketing. Newsreader stays selectable per fund; the two swap roles in DISPLAY_FONT_OPTIONS, in the PDF fallback, and in the four places that hardcoded the default key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o icon
Four fixes from a second look at the preview.
FAQ: no hairline under each row. Under a two-column grid they read as a
broken table rather than a list; the chevron and the row spacing already
separate the questions, so a small gap-y replaces the rules.
Eyebrows: removed. They restated the heading directly beneath them ("Pricing"
over "Pricing", "FAQ" over "Common Questions") and only appeared on three of
six sections, so they read as inconsistent decoration rather than structure.
The `eyebrow` field goes with them; parseSiteContent ignores the key, so
stored content that still carries one keeps parsing. The pricing and callout
BADGES keep text-eyebrow — different element, still earning its place.
Demo CTA: takes the Play icon the nav button already uses, so the same action
looks the same in both places. Nav label follows suit, "Try the Demo" ->
"Try the demo", matching the hero and the sentence case used everywhere else.
GitHub: a bordered pill with the star count set off by a divider instead of a
ghost link with the count wedged between the mark and the label. Counts over
a thousand render compactly (1234 -> "1.2k"). The count still appears only
once fetched and above the existing floor, so the button cannot reflow
mid-paint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in and View on GitHub move from ghost to outline. Both surfaces now read as one filled primary (Try the demo) against bordered secondaries, rather than mixing three weights in a single row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app ships with no serif. --font-display now resolves to Inter, same as --font-sans. The axis stays, and that is the point of the change rather than a side effect. Every heading that should follow a display face already carries `font-display` — marketing hero and section headings, report covers, letter mastheads, statement headers, PDF templates — so picking a serif in DISPLAY_FONT_OPTIONS repoints all of them at once. The design work is done; the default is simply "no serif". Source Serif 4, Newsreader and Libre Caslon Display stay as options and now all load with preload:false, since none are used by default. PDFs get cheaper rather than more expensive. DISPLAY_FACES holds only the serifs, so the 'inter' default emits NO PDFDisplay @font-face at all and PDF_DISPLAY lists 'Inter' second — it falls through to the copy already embedded as the body face instead of shipping a second one. Choosing a serif embeds that face and nothing else, as before. DISPLAY_FONT_OPTIONS gains 'inter' as the default key; the four places that hardcoded which key means "default" follow it. DESIGN.md's typography section was stale on two counts — it still named Newsreader, and it described the per-fund display axis as a future feature that "would have nothing to style" — both now corrected. Also carries the marketing copy already in the tree: metadata description "portfolio reporting" -> "fund operations", in all three OG/Twitter slots. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…llback text-display already carried -0.025em, so `tracking-tight` would have been a no-op. The scale's tracking ramp was tuned for a serif; Inter is drawn for text sizes and reads loose at 46px and 68px, so the two display steps go tighter (title -0.02 -> -0.025, display -0.025 -> -0.035). The ramp still widens as it descends: -0.035 / -0.025 / -0.01 / -0.005. fontFamily.display fell back to Georgia, which made the failure path swap a sans for a serif on the largest heading on the page. Sans fallbacks now. A fund that picks a serif still gets it from --font-display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…top gap Headings read thin because the "display type is light, not bold" rule was written when --font-display was a serif. Serifs carry visual weight a UI sans doesn't, so Inter at 400 reads thin at 46px and 68px rather than light. All seven display headings on the one-pager go to font-medium, which keeps the large-and-light intent while giving the hero presence. Still short of semibold, which is what would turn it into a dashboard. Hero top padding pt-16/pt-28 -> pt-8/pt-16. The nav sits directly above and already carries py-4, so the old value dropped the h1 well down the page. DESIGN.md and CLAUDE.md both pinned weight 400; both now say 500 and explain that the number follows the face — switch --font-display to one of the serif options and it goes back to 400. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the h1 on hemrock.com, so the marketing site and the app's own one-pager read as one product rather than two. The LP-facing document headings (portal letters, snapshots, report cards) stay font-normal. They sit at text-heading, 20-24px, where the type is near body size and Inter 400 is already right — the extra weight is a large-size fix, not a global one. DESIGN.md's rule was a flat "display type is light, never semibold" from when the display face was a serif, so it now contradicted the code twice over. It becomes a small table: weight follows size and face, with the serif fallback spelled out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Establishes a design system foundation with CSS tokens, typography hierarchy, and automated enforcement of design conventions.
Summary
This PR introduces a comprehensive design system documented in
DESIGN.md, replacing raw Tailwind palette classes with semantic tokens throughout the app. It adds a display font layer, establishes status and categorical color tokens, and implements test guards to prevent regression of the raw-palette rule.Key changes
Design tokens & documentation
DESIGN.mddocumenting the design system rationale, token usage, color palette, typography, and spacing conventionsapp/globals.cssfrom raw palette classes to semantic tokens:--background,--foreground,--card,--brand-*,--success,--warning,--info,--destructive, etc.#fdfdfc, ink#1c1a17) replace pure greyscale for a considered aesthetic--brand-50through--brand-950)Typography
--font-display(Newsreader) for report covers, letter mastheads, statement headersfont-displayTailwind class and named text sizes (text-eyebrow,text-label, etc.) intailwind.config.tsapp/layout.tsxto load Inter, Newsreader, Source Serif 4, and Libre Caslonlib/theme.tswithDISPLAY_FONT_OPTIONSanddisplayFontOf()for per-fund display font themingTest guards & enforcement
lib/design-tokens.test.tsto fail the build on new raw palette usages (e.g.,bg-amber-100,text-green-600), with an allowlist for categorical colors (relationship types, compliance categories) that genuinely need the palettelib/theme.test.tspinning the brand ramp against hardcoded values inglobals.cssto prevent driftlib/pdf-fonts.test.tsguarding PDF font embedding (Inter, Newsreader, Source Serif 4)Component & utility updates
components/ui/metric.tsx— a labelled KPI tile consolidating six near-identical copies across fund overview, fund detail, LP list, LP portal, and diligence summariescomponents/ui/empty-state.tsxfor whole-view "nothing here yet" statescomponents/ui/card.tsxto userounded-cardtoken instead of hardcodedrounded-xlcompliance/page.tsx,diligence/deal-detail.tsx,email-review-modal.tsx, etc.) from raw palette to tokens (bg-success-subtle,text-warning,text-info)PDF & rendering
lib/pdf-fonts.tsto exportpdfFontCss()andPDF_DISPLAYfor dynamic font embeddinglib/lp-report-pdf.tsandlib/accounting/lp-statement-pdf.tsto acceptdisplayFontparameter and embed the fund's display facePublic site
Eyebrowcomponent for small uppercase labels above section headingsapp/(public)/page.tsxto use brand tokens for featured CTAs and eyebrow labelsapp/(public)/layout.tsxwith brand accent stylingMiscellaneous
CLAUDE.mdwith styling conventions sectionlib/marketing/content.tsto support eyebrow labels on product groupsImplementation notes
lib/design-tokens.test.tsto prevent regressionRAMP_STOPSinlib/theme.ts) and pinnedhttps://claude.ai/code/session_018z2JjortExWjZVrXhNqquF