North Design System

Tokens and components. Spec lives in docs/DESIGN-SYSTEM.md. Contrast figures below are measured in your browser from the live tokens, not copied from the spec — change a token and this page reports it.

Wordmark

LOCKED. NORTH lettered in the same hairline hand that draws the turn glyphs, with the O replaced by a working compass — and the needle's north point is the one filled shape in the whole identity. Do not redraw: the geometry (cap 28, gaps 9, both needle halves on one pen) was settled by measurement.

Wordmark — masthead, product, and door sizes

The Plus lockup

Membership, worn by the mark. The wordmark itself is locked and untouched — the lockup EXTENDS it with a compass rose after the H, at the same optical gap and on the same pen. The rose's vertical axis is the needle's own geometry at half scale, its north point filled exactly as the needle's is: the identity's one filled shape restated, never a second kind of fill. A member's masthead carries this; everyone else's carries NORTH alone. Premium is drawn as more compass, because that is the only ornament this identity owns.

The Plus lockup, in the aurora

ONE PLACE ONLY: the moment Plus is being presented — the tier panel's header. Never chrome. A masthead that wears the sky on every screen spends the product's one gradient on furniture, and the one-signal-per-viewport rule would have nothing left for the thing that actually needs attention. Same three stops as .text-aurora, held to the AA-safe half of the ramp, but painted as an SVG gradient: background-clip:text fills glyphs a font drew, and a stroke is not one of those. It must be gradientUnits='userSpaceOnUse' — under the objectBoundingBox default every straight stroke in the mark (both N verticals, the R stem, the T, all of the H, the bezel ticks) has a zero-dimension bounding box and is not rendered at all.

Wordmark on the sky

The mark is ink or on-deep — never a hue, and never reversed out of a box.

CompassMark — the O alone

The app icon and favicon. Identical coordinates to the wordmark's O, cropped square, so the icon is a letter of the name rather than a second drawing.

Color

Three families, only three. The primary action is ink, not a hue — that sidesteps the entire fintech color problem. There are no red/green financial semantics, so nothing is encoded in hue and there is no colorblind remediation to do.

Ground

surface-page
surface-raised
surface-sunken
line-hairline

Ink

text-primary
text-secondary
text-tertiary
text-disabled

Signal

signal-coach
signal-attention
action-border

Text contrast

Every text token must clear 4.5:1 on surface-page. text-tertiary was corrected from #7C746A to #726B62 during authoring — it cleared AA on white cards but failed at 4.34 on the warm page ground.

Measured in-browser against --surface-page · requirement 4.5:1
TokenUseRatioVerdict
--text-primaryThe read, figures, primary action
--text-secondarySupporting prose, labels
--text-tertiaryMetadata, timestamps
--signal-coachCoach presence, focus ring
--signal-attentionAttention, max once per view

Non-text contrast

Control boundaries and graphics carry a 3:1 gate. line-hairline is intentionally below it — it separates and carries no meaning. text-disabled is barred from text by the same measurement.

Measured in-browser against --surface-page · requirement 3.0:1
TokenUseRatioVerdict
--action-borderControl boundary — was 1.68, fixed
--action-border-hoverControl boundary, hover
--signal-bandHorizon band — solid, never a tint
--text-disabledDecorative marks only, never text
--line-hairlineSeparation only — no meaning

Atmosphere — the coach's sky

The brand indigo made environmental: sky-900 → sky-600 → sky-300 → paper, interpolated in OKLab. Ground, not signal — it holds the top of a screen and always lands on paper. Text on it uses the on-deep tokens (13.67 / 7.35 / 4.73 verified).

Typography

Three faces, three jobs. Newsreader is the coach thinking; Geist Mono is the machine computing; Geist Sans is the interface getting out of the way. The largest thing on any screen is a sentence, not a number.

type-voice-lg

The read. Serif, 400 only, responsive 30 → 34.

Your $40,000 in checking is the problem, not the win.

type-voice-md

Onboarding questions. At the 20px serif floor.

What’s the money thing you keep meaning to deal with?

type-body

You’re holding 11 months of expenses in cash. Three is the floor.

type-overline

Mono caps micro-label — mastheads, section labels, stat lines. Always a label, never a sentence.

Today · Jul 27On this path by retirement

Emphasis & the aurora word

A read may emphasize AT MOST one figure — it breaks onto a display-serif line at weight 500, still carrying its confidence mark: the big bold number, inside the sentence. And each screen may fill AT MOST one word with the sky (.text-aurora, stops held to the AA-safe half of the ramp).

The other $104,000is waiting on your attention.

type-label / type-meta / type-figure

Open the IRA4 of 7 · tax-advantaged space$1,247,832

Spacing & radius

Spacing is Tailwind's own 4px scale, deliberately not renamed — the sanctioned steps are 4/8/12/16/24/32/48/64/96. Radius tokens are semantic so `rounded-lg` keeps its framework meaning.

Sanctioned spacing steps

p-1 · 4px
p-2 · 8px
p-3 · 12px
p-4 · 16px
p-6 · 24px
p-8 · 32px
p-12 · 48px
p-16 · 64px
p-24 · 96px

Radius

rounded-chip · 8px
rounded-control · 12px
rounded-card · 20px
rounded-sheet · 28px

Elevation & motion

Elevation never conveys hierarchy — only that a surface floats above scrolling content. Shadows are warm-tinted; a cool shadow on warm paper reads as dirt.

Elevation

shadow-card
shadow-sheet

Motion

120 micro · 220 standard · 320 considered · 700 ceremonial. Springs never overshoot. Numbers never animate upward — values cross-fade in place. prefers-reduced-motion collapses everything to opacity.

--motion-micro · --motion-standard · --motion-considered · --motion-ceremonial

Figure

The signature component and the only way a number enters the product. Every Figure carries a confidence, and that confidence is part of the accessible name — a screen reader user learns which numbers are theirs and which are North's guesses, exactly as a sighted user does.

Confidence grammar — prose context

stated is sharp and unmarked · inferred carries the dotted coach mark · assumed drops to secondary ink with a dashed mark. Inspect with a screen reader: each announces its provenance.

You hold in checking — about of expenses, growing at a year.

Interrogable

Renders as a button into the derivation sheet. The mark is small; the hit area is projected to 44px beyond the glyph bounds without disturbing text flow.

That leaves of unused tax-advantaged space this year.

Tabular context

Mono, tabular figures, for columns that align.

Formats

Buttons & input

One primary per screen. Selection is weight and surface, never a color fill. The product has exactly one text field — there is deliberately no form suite.

Button variants · md

Button · lg, full width, disabled, pending

ChoiceChip

Selected reads through border weight and surface lift. 44px minimum at every breakpoint, desktop included.

Slider

Built on native input[type=range], so the slider role, arrow stepping and touch come from the platform — only the paint is ours. The thumb is ink, like every primary control. `valueLabel` is what a screen reader announces, so it hears money rather than 6000000. Chips remain right for discrete choices; this is for ranges you want to explore.

$60,000
$20,000$200,000

TextField

Auto-growing, no placeholder, no label chrome — the question above it is the label. Enter submits; Shift+Enter breaks the line.

What’s the money thing you keep meaning to deal with?

Card, Row, Sheet

North has exactly one card and it is the decision — three variants, not three components. History is rows, not cards; that contrast is what makes the card mean something.

Card · decision

Your in checking is the problem, not the win.
4 of 7 · Tax-advantaged space
On this path by 65

Card · callback

The return state. The moat, in one sentence.

You said you'd open the IRA. Did that happen?

Card · satisfied

A reward state, not an empty state.

Nothing needs your attention. That's rare.

Row · three outcomes

held · deferred · open. There is deliberately no failed, missed, or overdue — guilt is a retention bug. Marks come from ONE shared component (OutcomeMark) rather than being drawn per surface: Row kept its own set until July 2026, including a bare minus for deferred, which is the shape review rejected for reading as deletion. Distinguished by SHAPE, never hue — a tick for held, a returning loop for set aside (it comes back around), an unclosed ring for the one still waiting. Dashing the tick to mean deferred was tried twice and abandoned: at 16px only silhouette survives.

Move eight months of cash out of checkingOpen · today

Sheet — every overlay in the product

Native <dialog> — focus trap, Esc, and focus restoration come from the platform. Below lg (1024px) it is a bottom sheet with drag-to-dismiss; at lg and up a full-height pane pinned to the right edge. Never a centered modal: a modal reads as a separate surface hovering over the page, and a disclosure is closer to the rest of what is already on screen. Tablet sits on the sheet side of that split — the surface is still held and tapped, not pointed at. Opening pushes a history entry so system back dismisses it. Resize the window to see both presentations. All three buttons below open the SAME component; only the header differs — a plain label for one number's arithmetic, an eyebrow-and-thesis for reasoning meant to be read.

Horizon & rung

MVP contains one visualization, and calling it a chart overstates it. No axes, no gridlines, no legend, no tooltip.

HorizonBand — the width IS the uncertainty

Both bands share a domain. The top is a confident profile; the bottom is the same projection with less known about the person. You can see that North knows less without reading a word.

On this path by 65
On this path by 65

HorizonField — the projection as a landscape

The large-format sibling of HorizonBand, for when the projection IS the subject (Goals, the calm day). The in-band bars breathe on a slow stagger — uncertainty is alive — and with onScrub it becomes a real slider: drag to move the horizon year under a plumb-line cursor. role=slider with arrow keys and the year in aria-valuetext.

RungIndicator

Typography is the primary form; ticks are a quiet secondary. Never celebrates advancement — no animation on change, no completion state, no streak.

1 of 7 · Liquidity floor
4 of 7 · Tax-advantaged space
7 of 7 · Optimization
4 of 7 · Tax-advantaged space

InsightSheet

The room behind one dispatch in the letter. Every sentence on Insights is the visible tip of a real calculation — the buffer entry sits on explain(), the ladder entry on a full evaluate(), the record entry on the actual ledger — and none of it was reachable. It uses ExplanationPanel, not DerivationSheet, per the distinction the system already draws: a DerivationSheet shows ONE number's arithmetic, an ExplanationPanel shows the reasoning behind a whole answer, meant to be read. core/insights/behind.ts returns null where there is nothing honest to add, and then the dispatch simply isn't openable — a room that opens onto 'there is nothing here' is worse than a sentence that never offered to open.

MembershipSheet

Plus proposed, managed, and ended in one room — the same sheet from every entry point, so manage is never a different place than the one that sold it to you. Opened contextually (a locked scenario row, the voice launcher on free, a second goal), never as an interstitial; managed from the masthead's PLUS wordlet, which shows only to members — status, never an ad. The honesty line (NEVER_GATED) ships from core with the rules it describes, and the rules' tests assert it still tells the truth. Ending is one tap, runs to period end, and 'Keep Plus' undoes it in place: no confirmation dialog, no retention flow. The state shown depends on the live membership store — flip it with the tier toggle on /preview.

RouteMarks

Where you are on a route: answered, here, still to come. Onboarding drew this twice in two different visual languages — the phone strip used equal-width bars separated only by opacity, the desktop rail used varying length plus three colour tokens — so the same information changed vocabulary at 1024px. One vocabulary now, and the richer of the two: here is long and full strength, answered is short and half lit, to come is short and barely there. Length AND weight carry it, so it survives a glance in a masthead and never rests on colour alone. Marks paint in currentColor and know nothing about their ground, which is what lets one definition hang on the sky and on paper. `onJump` is optional like RungIndicator's `onInterrogate`: a desktop margin has room to give every mark a real 44px target, a phone masthead does not — ten of those is 440px of touch target in 375px of screen — so the strip is decorative and going back there is the swipe.

SectionHeading

The heading that opens a block within a screen — and it takes no tone and no variant. A section variant existed and every heading used it, but each call site picked its own tone, so one rank was rendering at three greys across the product and the horizon caption had drifted to type-overline entirely. A rank whose appearance is chosen per call site is not a rank. Mono caps 13/500/0.1em — heavier and larger than type-overline, which is the quieter mark reserved for a page's dateline.

What you’re working toward

On this path by retirement

Where this leaves you

Or start from one of these

RungIndicator — interrogable

With onInterrogate it becomes a button into the ladder disclosure, wearing the same dotted rule an INFERRED figure wears — because that is what it is: a derived reading of your situation. It states a position, and a position invites exactly one question: what are the other six, and why is this one mine? Without the disclosure the mark was a dead end, which is the one thing no figure in North may be — a number the reader cannot interrogate is a number taken on faith.

Conversation

The turn primitive for the correction loop and goal capture — the reuse that removes a settings screen from the IA. Onboarding itself composes full-viewport sections and no longer uses this. One question at a time, and no workload meter.

ConversationTurn — answered, then active

What's the money thing you keep meaning to deal with?

I have cash piling up and no idea what to do with it.

Roughly what does your household bring in?

CoachPresence

The considered pause. 400–600ms, and it must always resolve into visible content. Not a spinner — there are no spinners.

Marks & voice

Product-tier marks (components/north), shown here so the system stays honest about every mark it makes. Six hairline strokes on a uniform skeleton — every figure interpolates into every other; nothing is ever swapped.

TurnGlyph — the morph

Pick a figure and watch the same six lines re-arrange on SPRING.fluid. The component is mounted once and fed new turn ids — keying it on the turn would remount, and a remount is a swap, not a morph.

Every figure

Authored as anchor grids, never as path data: 6 strokes × 6 anchors through one Catmull-Rom converter, so every figure compiles to the same M + 5C skeleton. Layered stroke opacities give depth; the ambient drift stills under reduced motion.

Bearings
Footing
The inflow
The outflow
Reserves
Drag
The weight
Leaning in
The long game
Matched
Horizon
Destination
The mark
The date
Underway

ExplanationPanel — AssumptionsPanel and the ladder

A skin over Sheet, not a second dialog. It used to be a bespoke full-height room under its own band of sky, with its own Escape listener, its own scroll lock, no history entry and no exit animation. The sky went because a masthead-sized band of atmosphere read as chrome bolted onto a room that otherwise wears the same surface as every other sheet; the hand-rolled dismissal went because it was a worse copy of what <dialog> already gives. What is genuinely its own is the HEADER: an eyebrow and a full sentence in the coach's voice, because the reasoning behind a whole answer is meant to be read, where a number's receipt is scanned and dismissed in ten seconds. Same body surface, same breakpoints, same four ways out.

SpokenField

The opening turn's answer, and Navigator's door. A composer: an opening quote, your words in the voice face, and the actions inside one sheet — the box contains real things, so it never reads as a void. The hanging quote is load-bearing: Today opens with 'You said …', so what you write here is literally what the coach quotes back. Raised white, hairline edge, no shadow — the one elevated thing on a screen is the Card — and focus darkens the sheet's own border rather than drawing a ring inside it. Click anywhere on the sheet to write. The mic pill appears only where the Web Speech API exists (gated on hydration, so SSR never guesses), and no listening failure is ever announced.

SpokenField — with offers

The same field, carrying the questions worth asking. They occupy the placeholder's position and clear the moment you type, but they are BUTTONS, not placeholder text: core/coach/offers.ts exists because seven of the eleven things this coach can answer had no tap path anywhere in the product, and real placeholder text would put six of them back out of reach and show the seventh. Set in the voice face and italic — the same face your typed words take — because tapping one IS saying it, and wearing the dotted signal-coach rule every other tappable thing in the product wears. The field and the offers share one grid cell, so the writing area sizes itself to whichever is taller and no count needs a magic height.

Action marks

North has no icon library and will not gain one — every mark comes from the same hairline pen as the glyphs and the horizon fan. Every mark here was chosen in user review — July 2026 for the first set, August for the account’s four — after the losing drawings failed the 16px read. Candidates are judged in the ROW they join, never alone: a mark’s job is to be told apart from its neighbours, and “Hide it” beside “Download it” is where the mirrored-tray candidate died, and “I’ve already done this” sits next to BackMark for the same reason. 1.25 stroke on a 16 grid, a hair lighter than the nav glyphs because they sit beside meta text and must not outweigh it. The mark is OPTIONAL — “Never mind” goes bare on purpose, being the quiet half of a pair beside a bordered pill.

Why these shapes

Asterisk: print's own footnote mark — 'there is fine print behind this figure,' which is exactly what the assumptions disclosure is. Signpost: naming a goal is putting up the sign that says where next — the product's verb (name a destination), not the spreadsheet's (add a row). Wind: gusts curling away — released to the weather, kind rather than punitive, and the sky already owns the weather. Direction in Insights is two ticks on a ground line — before and after, a comparison drawn as a comparison. Rejected on the way here, recorded so they aren't re-proposed: bare dashes (hamburger menu), a minus (deletion), abstract domes (illegible at 16px), balloon/bird/kite (draw badly at this size).

assumed
name
release
direction

ActionLink — the tertiary action, and the only one

ONE shape for every quiet action: a drawn mark, then a label carrying a dotted rule in signal-coach — the same rule an inferred Figure wears, because it means the same thing (there is more behind this). Before the pattern, tertiary actions were a scatter of bare text buttons with trailing arrows in three type sizes, and the only way to know one was pressable was to try it. A trailing arrow is NOT part of it: the mark leads, and an arrow on the other end is a second icon saying the same thing. Also: a quiet Button carries padding, so its label sits indented from the content it belongs to and no negative margin fixes that honestly. This is flush left by construction; hit-target grows the touch area to 44px without adding a visual box, so the alignment stays true.

Body text sets the left edge.

MarkedLine — the mark sits on the line, not near it

ActionLink is a mark against one line; this is a mark against a sentence that wraps. `items-start` aligns two BOXES, which is not the same as aligning a glyph with text — a 16px mark inside a 26px line box sits five pixels high of where the eye expects it. Every such row had grown its own hand-tuned nudge (mt-2, mt-1, mt-[0.45em]), and by August 2026 all three were wrong: each was measured once against one type size and then outlived it. The em-based one was the worst kind of wrong, because it moved when the type changed but in the wrong direction — a correction that tracks the type and still misses is harder to spot than one that never moves. This takes the measurement the browser already has: the mark's wrapper is h-[1lh], exactly one line-height of the row it sits on, so a glyph centred inside it is centred on the line at any size, with no number in the source. The one rule it asks for — the type class goes on the ROW — is also why it cannot drift: the declaration that sets the line and the declaration that measures it are the same one.

Meta, on an 18px line. The glyph is the same 16px drawing in all three, and it stays level with the first line however far the sentence runs on past it.
Body, on a 26px line. The glyph is the same 16px drawing in all three, and it stays level with the first line however far the sentence runs on past it.
Voice, on a 34px line. The glyph is the same 16px drawing in all three, and it stays level with the first line however far the sentence runs on past it.

Empty & error

An empty state is never 'no data' — it is 'here's what happens next'. An error must never imply the user's money is affected, and the coach never breaks character.

EmptyState

Nothing here yet.

The first decision you make will land here, along with whether you held it.

ErrorState

`unaffected` is a required prop — you cannot render an error here without saying what is still fine.