Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3b7942d
feat(design): evergreen accent, Inter + Newsreader, marketing restyle
claude Jul 26, 2026
5becf70
docs(design): correct why Newsreader is not in FONT_OPTIONS
claude Jul 26, 2026
3b9854c
feat(theme): per-fund report font, and fix font drift in PDF exports
claude Jul 26, 2026
ae0f357
refactor(ui): figures use tabular figures, not a monospaced face
claude Jul 26, 2026
b8c03d0
refactor(ui): status tokens and one card radius
claude Jul 26, 2026
83c46dd
feat(ui): display face on LP report surfaces, and a token guardrail
claude Jul 26, 2026
089954e
feat(ui): categorical palette for identity, retiring the last raw col…
claude Jul 26, 2026
befd46a
refactor(ui): one Metric component, and a page-width convention
claude Jul 26, 2026
b4a9555
refactor(ui): shared EmptyState, and drop the stale api/contact entry
claude Jul 27, 2026
3d6a86c
feat(ui): give the empty states their actions
claude Jul 27, 2026
4a90d91
refactor(ui): restore the middle rung of the type ladder
claude Jul 27, 2026
fc5791f
chore: remove Dependabot version updates
claude Jul 27, 2026
22411e1
chore: trigger preview deploy
tdavidson Jul 27, 2026
840291a
feat(marketing): centre the one-pager, add hero CTAs, swap display face
tdavidson Jul 27, 2026
c97d2ad
refine(marketing): drop eyebrows and FAQ rules, GitHub star pill, dem…
tdavidson Jul 28, 2026
55cbc8f
style(marketing): border the secondary actions in the nav and hero
tdavidson Jul 28, 2026
80f4e1b
refactor(design): Inter for display too; keep the serif axis wired
tdavidson Jul 28, 2026
78995d3
style(design): tighten the display steps for Inter, drop the serif fa…
tdavidson Jul 28, 2026
54b0d5e
style(marketing): display headings to weight 500, tighten the hero's …
tdavidson Jul 28, 2026
28a5928
style(marketing): display headings to weight 600
tdavidson Jul 28, 2026
0aaff8a
style fix and demo update
tdavidson Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ CRON_SECRET=
# Set to "true" to seed demo data and disable email parsing
# DEMO_MODE=true

# ── The public demo (/demo) ────────────────────────────────────────────────
# Without all four of these, /demo refuses to start and says so; nothing else breaks.
#
# The demo signs visitors into ONE shared account server-side — the password below never
# reaches a browser. That account MUST be a `viewer` in its fund, or the action refuses to
# mint a session: `viewer` is what makes the demo read-only, and it is checked on every visit
# rather than assumed. Point DEMO_USER_EMAIL at a mailbox you control (account mail goes
# there), and prefer a domain that is not registered as any fund's `email_domain`.
# NEXT_PUBLIC_ENABLE_MARKETING_SITE=true
# MARKETING_DEPLOYMENT_KEY=
# DEMO_USER_EMAIL=
# DEMO_USER_PASSWORD=

# Diligence call transcription (Deepgram). Optional unless you upload
# audio/video recordings; missing keys only break that one flow.
# DEEPGRAM_API_KEY=
Expand Down
15 changes: 0 additions & 15 deletions .github/dependabot.yml

This file was deleted.

6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ supabase/.temp
# (lib/access/route-domains.ts) so the route-coverage test doesn't call its registry entry stale
# when the file isn't here.
#
# NOTE: /api/demo/credentials is NOT ignored — it's the generic demo sign-in and has no Hemrock in
# it. Nor is scripts/demo-agent-account.ts, which is deliberately decoupled from these fixtures.
# NOTE: the demo SIGN-IN is not ignored and never should be — it's generic, with no Hemrock in it.
# It now lives in the `startDemo` server action (app/demo/actions.ts), which replaced the old
# /api/demo/credentials route. Nor is scripts/demo-agent-account.ts ignored, which is deliberately
# decoupled from these fixtures.
/lib/demo/
/app/api/demo/seed/
/scripts/run-demo-seed.ts
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

Conventions baked in to keep automated edits safe across this repo. Read before generating migrations or refactoring data-access code.

## Styling

`DESIGN.md` is the design system; `app/globals.css` holds the tokens. Read it before writing UI.

The short version, because these are the mistakes that actually get made:

- **Never use raw Tailwind palette classes** — `bg-amber-100`, `text-green-600`, `border-blue-500`. They break per-fund white-labelling, because `themeCssVars()` can't repoint them. Use the tokens: `bg-warning-subtle`, `text-success`, `border-brand-200`, `text-muted-foreground`. `lib/design-tokens.test.ts` fails on new ones; the only exemptions are files using colour *categorically*, allowlisted there with a reason.
- **Numbers use `tabular-nums`, not `font-mono`.** Mono is for content a machine reads literally — code, IDs, OTP inputs. Financial figures are not code. Same rule in the PDF templates.
- **Cards use `rounded-card`**, controls use `rounded-lg`/`md`/`sm`. `--radius` is the control radius (0.25rem), `--radius-card` the card one (0.5rem).
- **`--primary` is the deployment's action colour** (fund-themeable). **`--brand` is Hemrock's** (evergreen, marketing). They are not interchangeable.
- **Accent text needs a dark-mode pair**: `text-brand-700 dark:text-brand-400`. The 700 stop fails contrast on the dark surface.
- **Display weight follows size and face.** Marketing headings (`text-display`/`text-title`) are `font-semibold`; LP-facing document headings (`text-heading`) stay `font-normal`. Both numbers assume `--font-display` is Inter — a serif display face wants 400 throughout.

## Migration conventions

### Every new `create table` migration requires explicit Data API grants
Expand Down
306 changes: 306 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
# Design system

The tokens in `app/globals.css` are the source of truth. This file explains what
each one is *for*, so the answer to "what size is a heading" or "what colour is a
warning" lives next to the code rather than in a slide deck.

Background and rationale: `plans/plan-design-system.md`.

## The one rule

**Use tokens, never raw palette classes.** `bg-amber-100`, `text-green-600` and
`border-blue-500` are all bugs — not because they look wrong, but because a fund
that white-labels the app still gets amber locks and blue chips. Every colour
must resolve through a CSS variable so `themeCssVars()` (`lib/theme.ts`) can
repoint it.

This was migrated in bulk: 996 raw palette usages across 105 files became 23,
and `lib/design-tokens.test.ts` now fails the build on new ones. The 23 that
remain are colour used *categorically* (relationship types, compliance
categories, LP activity kinds) rather than as status; they are allowlisted by
file, with a reason, and need a categorical palette rather than a status token.

## Colour

### Neutrals

Warm-tinted, not pure greyscale — paper is `#fdfdfc`, ink is `#1c1a17`. The cast
is under 1% chroma: it reads as considered rather than coloured, and it is the
main thing separating this from a default shadcn install.

| Token | Use |
| --- | --- |
| `--background` | The page. Tinted paper |
| `--foreground` | Body text |
| `--card` | Pure white — cards lift off the paper without needing a border |
| `--muted` / `--muted-foreground` | Secondary surfaces and secondary text |
| `--border` / `--input` | Hairlines. 1.34:1 against paper — visible, not loud |
| `--accent` | shadcn's **subtle hover surface**. NOT the brand accent |

`--accent` keeps its shadcn meaning because renaming it would touch every
`hover:bg-accent` in the app. The brand accent is `--brand`.

### Brand accent — evergreen

Hue 164. Eleven stops, `--brand-50` … `--brand-950`, generated from a fixed
saturation/lightness curve (`RAMP_STOPS` in `lib/theme.ts`). Every stop is
contrast-verified; `lib/theme.test.ts` pins the ramp against the values
hardcoded here so the two cannot drift.

| Role | Stop | Contrast |
| --- | --- | --- |
| Primary CTA fill (light) | `brand-700` `#276353` | white text at 7.05:1 |
| CTA hover / pressed | `brand-800` | |
| Link + accent text on paper | `brand-700` | 6.91:1 |
| Focus ring | `brand-600` | |
| **Dark-mode accent text** | `brand-400` / `brand-500` | 7.06 / 5.14:1 on the dark surface |
| Tinted surfaces | `brand-50` / `brand-100` | |
| Borders on tinted surfaces | `brand-200` | |

Light mode uses 700 for accent text; **dark mode must use 400 or 500** — 700 on
the dark surface is 2.40:1 and fails. Write it as
`text-brand-700 dark:text-brand-400`.

### `--primary` vs `--brand`

Two different jobs, and the distinction is load-bearing:

- **`--primary`** is *this deployment's* action colour. Neutral by default; the
per-fund theme overrides it. A fund's buttons should be the fund's colour.
- **`--brand`** is *Hemrock's* colour. The marketing site is not under a fund
theme, so it always renders evergreen.

Inside the app, `themeCssVars()` regenerates the whole `--brand-*` ramp from the
fund's accent hue, so tints and hairlines follow the fund rather than falling
back to evergreen. The fund's chosen value stays the fill — it is **not**
relocated onto stop 700, because amber pushed to 700 is brown.

### Status

Amber is a **warning**, not a brand colour. That is the whole reason a separate
accent exists. Each status token is verified both as text on paper and as a fill
with white text (≥ 4.5:1 either way).

| Token | Meaning | Light |
| --- | --- | --- |
| `--success` | Completed, reconciled, passing | `#1f7a50` |
| `--warning` | Needs attention, locked, beta, stale | `#a56112` |
| `--info` | Neutral notice | `#1f61ad` |
| `--destructive` | Failed, deleting, irreversible | `#bc2424` |

Each has a `-foreground` (text on the fill) and a `-subtle` (tinted background).
Use `bg-warning-subtle text-warning` for a callout, `bg-warning
text-warning-foreground` for a badge.

### Categorical — `--cat-1` … `--cat-8`

For **identity**: telling one kind of thing from another. Compliance categories,
relationship tags, chart series. Not for state — that's what the status tokens
are, and they are reserved.

Two rules, both load-bearing:

1. **Fixed order, never cycled.** Slot N is always the same hue. A category keeps
its colour regardless of which categories are on screen — colour follows the
entity, never its rank. Past the last slot, fold into "Other"; never generate
a hue.
2. **Respect the series ceiling.** It depends on the chart form, because it
depends on which pairs a reader compares:

| Form | Pairs compared | Ceiling |
| --- | --- | --- |
| Stacked bars, lines, chips, legends | adjacent | **8** (all slots) |
| Pie, scatter, bubble — anything where any two marks sit together | all | **4**, and only slots 1,4,5,6 |

These were computed with the dataviz skill's validator against this app's own
surfaces (`#ffffff` light card, `#262422` dark card), not chosen by eye. Re-run
it before changing a slot:

```
node <skill>/scripts/validate_palette.js "#2a78d6,#eb6834,…" --mode light --surface "#ffffff"
```

Slots 3, 4 and 5 sit under 3:1 on white. Anything using them must carry a visible
label — which is why category chips are neutral with a **coloured dot**, and
relationship tags are ink text on a **15% tint**, rather than coloured text on a
coloured fill.

Dark mode is a *selected* set of steps for the dark surface, not an automatic
flip of the light values.

### Charts

`--chart-1` … `--chart-5` are aliases onto `--cat-1` … `--cat-5`, kept as their
own names because `fund-detail-view` and `metric-chart` already reference them.
The stacked bars use the adjacent-pairs ceiling (5 of 8 is fine); the pie uses
the all-pairs subset and folds the tail into "Other".

## Typography

**Inter** for everything — body (`--font-sans`) *and* display (`--font-display`,
`font-display` in Tailwind). The app ships with no serif.

`--font-display` is still a separate axis, and that is the point. Every heading
that should change when a display face is chosen already carries `font-display`:
the marketing hero and section headings, report covers, letter mastheads,
statement headers, and the PDF templates. Picking a serif in
`DISPLAY_FONT_OPTIONS` (`lib/theme.ts`) 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 ship as options and load only when selected.

That list is kept out of `FONT_OPTIONS` because `FONT_OPTIONS` drives
`--font-sans` — the *body* font for the whole app. A serif there would land on
every dense financial table, which is not what a display face is for.

Because the default is Inter, the PDF pipeline emits **no** `PDFDisplay`
`@font-face` at all: `PDF_DISPLAY` lists `'Inter'` second, so it falls through to
the copy already embedded as the body face. Choosing a serif embeds that one
face and nothing else (`lib/pdf-fonts.ts`).

Inter's figures are proportional by default with `tnum` available, which is the
profile this product wants: proportional in prose, tabular on demand in tables.
Worth knowing if the body font is ever revisited — Hanken Grotesk and Plus
Jakarta Sans are both loaded here, and Hanken Grotesk's figures are
*permanently* tabular (all ten digits share one advance width), so it can't do
proportional numerals in running text.

Named steps, defined in `tailwind.config.ts`. Each carries its own line-height
and tracking, so an eyebrow can't be reassembled wrongly by hand:

| Class | Size | Use |
| --- | --- | --- |
| `text-display` | clamp 42 → 68px | Marketing hero. **Weight 400** |
| `text-title` | clamp 30 → 46px | Marketing section headings. **Weight 400** |
| `text-heading` | clamp 20 → 24px | Sub-headings, prices |
| `text-lede` | 18px / 1.65 | Hero subtitle, section intros |
| `text-label` | 13px | Dense UI labels |
| `text-caption` | 12px | Fine print |
| `text-eyebrow` | 11px / 700 / uppercase / .09em | Section eyebrows |

Tailwind's `text-xs` … `text-2xl` still work and remain correct for dense app UI.

### The app ladder

The named steps above are for **marketing** — their clamps are wrong for dense
app chrome. In-app, use Tailwind's plain steps, in five rungs:

| Rung | Class | Use |
| --- | --- | --- |
| Page title | `text-2xl font-semibold tracking-tight` | One `<h1>` per page |
| Major section | `text-lg` | An `<h2>` leading a whole page region |
| Section heading | `text-base` | Ordinary `<h2>`/`<h3>` |
| Body | `text-sm` | Paragraphs, table cells, controls |
| Caption | `text-xs` | Form labels, hints, timestamps |

The middle rung is the one that used to be missing: 79 headings sat at `text-sm`
or `text-xs`, the same size as the paragraph beneath them, so hierarchy rested
entirely on font-weight. `lib/design-tokens.test.ts` fails on an `<h2>`/`<h3>` at
body size or smaller.

**Errors and warnings are `text-sm` or larger**, never `text-xs` — the one thing
a user must read should not be the smallest type on screen. Also guarded.

Note that `text-xs` is *not* mainly a table style here: only ~36 of its uses are
in `<td>`. It is mostly labels and hints, which is what it's for.

**Display weight follows the size and the face**, and both changed when
`--font-display` became Inter.

| Where | Size | Weight |
| --- | --- | --- |
| Marketing headings — hero, sections, stat totals | `text-display` / `text-title` (46–68px) | `font-semibold` |
| Document headings — LP letters, snapshots, report cards | `text-heading` (20–24px) | `font-normal` |

The old rule was a flat "display type is light, never semibold", written when
the display face was a serif. Serifs carry visual weight a UI sans doesn't, so
Inter at 400 reads *thin* at 68px rather than light, and the marketing page
needs 600 — which also matches the `h1` on hemrock.com, so the two properties
read as one product. At `text-heading` the type is near body size, Inter 400 is
already right, and the LP-facing documents keep it.

If you point `--font-display` at one of the serif options, the marketing
headings want `font-normal` again.

### Numbers

**Use `tabular-nums`, not `font-mono`.** Column alignment needs tabular figures;
Inter ships them. `font-mono` costs letterform quality and makes money look like
code. This applies to PDF templates too — they use
`font-variant-numeric: tabular-nums` rather than a monospaced family.

`font-mono` is for content a machine cares about literally: code blocks, IDs,
API keys, GA/Fathom IDs, model names, account codes, version strings, OTP
inputs. `lib/design-tokens.test.ts` fails on `text-right` combined with
`font-mono`, which is the signature of a financial table cell.

## Radius

`--radius: 0.25rem` for controls, `--radius-card: 0.5rem` for cards (matching
hemrock.com). Use `rounded-card` for card surfaces, `rounded-lg`/`md`/`sm` for
controls. A per-fund `radius` overrides `--radius` and derives `--radius-card`
one step softer.

## Elevation

Restrained by design. `shadow-sm` on cards; the tinted paper does the separating.
Shadows don't read in dark mode, so pair them: `shadow-sm dark:shadow-none
dark:border`.

## Motion

`--ease-out` (`ease-out-soft`) for most transitions, `--ease-expo` (`ease-expo`)
for larger movements. Keep durations at or under 200ms.

## Layout

| | |
| --- | --- |
| Marketing container | 1100px |
| Prose measure | 640px |
| Marketing section rhythm | 80–112px (`mb-20 md:mb-28`) |

**Two app page widths, and only two.**

| Class | Width | Use |
| --- | --- | --- |
| `max-w-page` | 1280px | The app-wide cap. Applied **once**, on the wrapper in `app/(app)/layout.tsx` |
| `max-w-readable` | 46rem | Forms and prose, where a full-width line is harder to read |

Don't add a third. A page that wants to be narrower than `page` uses `readable`;
pages previously picked `max-w-6xl`, `max-w-7xl` and `max-w-screen-xl` more or
less at random, which is why nothing lined up between routes.

`max-w-*` on a card, modal, popover or truncated label is a different thing and
is fine — this rule is about *page containers*.

## Figures

`components/ui/metric.tsx` is the KPI tile: an eyebrow label over a large
tabular-figure value. Use it rather than hand-rolling — six near-identical copies
had already drifted apart on size, label position and whether the value carried
tabular figures.

A figure is what the reader came for, so it gets the promotion the rest of the
dense UI doesn't: it's the largest type on most pages. That's the shape of the
type hierarchy generally — **promote the few things that carry meaning, and leave
12/14px as the dense-table default.** Making everything bigger is not the goal.

---

## Divergences from hemrock.com/brand

The published guide is accurate on colour — its Ink / Paper / Dark / Muted /
Border values match this repo's tokens exactly. It needs these additions to
describe what actually ships:

1. **Typeface.** Already correct (Inter), now also true here — this repo
previously defaulted to the system stack.
2. **Radius.** Add the `0.25rem` control / `0.5rem` card pair. The guide is
currently silent.
3. **Tinted neutrals.** Paper is `#fdfdfc` and ink `#1c1a17`, not `#FFFFFF` and
`#0A0A0A`. The guide's values are the untinted originals.
4. **"Monochrome by design"** needs amending. The *mark* stays monochrome — those
logo rules are good and unchanged. The *product* now has one accent
(evergreen) plus a defined status set.
5. **Everything under Typography, Status, Radius, Elevation and Motion above** is
absent from the guide entirely. That is why nobody used it.
Loading
Loading