Skip to content

Design system: tokens, typography, and test guards - #27

Merged
tdavidson merged 21 commits into
mainfrom
claude/marketing-style-review-hoqy0b
Jul 28, 2026
Merged

Design system: tokens, typography, and test guards#27
tdavidson merged 21 commits into
mainfrom
claude/marketing-style-review-hoqy0b

Conversation

@tdavidson

Copy link
Copy Markdown
Owner

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

  • Added DESIGN.md documenting the design system rationale, token usage, color palette, typography, and spacing conventions
  • Migrated app/globals.css from raw palette classes to semantic tokens: --background, --foreground, --card, --brand-*, --success, --warning, --info, --destructive, etc.
  • Warm-tinted neutrals (paper #fdfdfc, ink #1c1a17) replace pure greyscale for a considered aesthetic
  • Brand accent (hue 164) with eleven contrast-verified stops (--brand-50 through --brand-950)

Typography

  • Added --font-display (Newsreader) for report covers, letter mastheads, statement headers
  • Added font-display Tailwind class and named text sizes (text-eyebrow, text-label, etc.) in tailwind.config.ts
  • Updated app/layout.tsx to load Inter, Newsreader, Source Serif 4, and Libre Caslon
  • Extended lib/theme.ts with DISPLAY_FONT_OPTIONS and displayFontOf() for per-fund display font theming

Test guards & enforcement

  • Added lib/design-tokens.test.ts to 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 palette
  • Added lib/theme.test.ts pinning the brand ramp against hardcoded values in globals.css to prevent drift
  • Added lib/pdf-fonts.test.ts guarding PDF font embedding (Inter, Newsreader, Source Serif 4)

Component & utility updates

  • Added components/ui/metric.tsx — a labelled KPI tile consolidating six near-identical copies across fund overview, fund detail, LP list, LP portal, and diligence summaries
  • Added components/ui/empty-state.tsx for whole-view "nothing here yet" states
  • Updated components/ui/card.tsx to use rounded-card token instead of hardcoded rounded-xl
  • Migrated status color maps across the app (compliance/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

  • Updated lib/pdf-fonts.ts to export pdfFontCss() and PDF_DISPLAY for dynamic font embedding
  • Updated lib/lp-report-pdf.ts and lib/accounting/lp-statement-pdf.ts to accept displayFont parameter and embed the fund's display face

Public site

  • Added Eyebrow component for small uppercase labels above section headings
  • Updated app/(public)/page.tsx to use brand tokens for featured CTAs and eyebrow labels
  • Updated app/(public)/layout.tsx with brand accent styling

Miscellaneous

  • Updated CLAUDE.md with styling conventions section
  • Replaced hardcoded color strings with tokens across 100+ files (investments, compliance, diligence, emails, settings, etc.)
  • Updated lib/marketing/content.ts to support eyebrow labels on product groups

Implementation notes

  • The raw-palette migration was bulk (996 usages → 23 allowlisted), guarded by lib/design-tokens.test.ts to prevent regression
  • Brand ramp is generated from a fixed saturation/lightness curve (RAMP_STOPS in lib/theme.ts) and pinned

https://claude.ai/code/session_018z2JjortExWjZVrXhNqquF

claude added 11 commits July 26, 2026 02:43
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
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
reporting Ready Ready Preview, Comment Jul 28, 2026 12:43pm

Request Review

claude and others added 2 commits July 27, 2026 13:01
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>
@tdavidson
tdavidson merged commit 1b31ecf into main Jul 28, 2026
3 checks passed
@tdavidson
tdavidson deleted the claude/marketing-style-review-hoqy0b branch July 28, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants