Can I measure your visit?

With your permission, PostHog and Google Analytics record how you use this site, including a replay of the visit with anything you type hidden. Your questions to the AI agent and its answers go to PostHog too, and are deleted after 30 days. None of it is used for advertising. If you say no, nothing is stored on your device.

What's collected

Your browser asks sites not to track you, so this visit won't be measured: nothing is stored on your device. What's collected

Skip to content
Pedro Sant'Anna Brazilian product designer in London.

Brutal.

Brutal exists to give dense work the room it needs: long reads, big images, a lot of detail. The page should feel well made, light and sure of itself. Each decision below comes from that, and it holds on a screen, in print or in a deck.

01

Colour

The palette stays neutral so the work can bring the colour. A warm ramp runs from paper to ink, which lets images keep their own palette and lets text hold its weight. The chips read the CSS var and the hex comes from tokens.ts, so if the two ever disagree you'll see it here.

Ground and ink

--bg #FAF8F4 #161310
--ink #161310 #F4F1EC
--sub #6F6A64 #9A948C
--muted #767068 #8A847B
--soft #5A554F #B8B2A9

Lines and edges

--line rgba(22,19,16,0.12) rgba(255,255,255,0.12)
--line2 rgba(22,19,16,0.14) rgba(255,255,255,0.14)
--edge #161310 rgba(255,255,255,0.44)
--idle-border rgba(22,19,16,0.28) rgba(255,255,255,0.26)

The gesture

--fill #161310 #F4F1EC
--fill-ink #FAF8F4 #161310

Surfaces

--num #B5AFA6 #6E685F
--stage #EDEAE4 #0E0C0A
--scrim rgba(8,7,6,0.46) rgba(8,7,6,0.46)

Elevation

--shadow-raised 0 12px 28px -10px rgba(0,0,0,0.4) 0 12px 28px -10px rgba(0,0,0,0.4)
--shadow-overlay 0 40px 90px -30px rgba(0,0,0,0.6) 0 40px 90px -30px rgba(0,0,0,0.6)

Client evidence

Client colour appears only as a band behind a client's own work, inside that client's case, and never on text, links, controls or chrome.

--accent-getninjas #FFC500 #FFC500
--accent-getninjas-soft #FFEA8D #FFEA8D
--on-accent #161310 #161310
02

Type

General Sans handles structure and can go very large without losing its shape. Neco handles long reading. Each stays in its role, and bold inside prose switches to the sans deliberately.

Seven steps, each solved for 390px to 1440px and held outside it. Everything meant to be read takes a step. The sizes below are rendered at their own value, straight from tokens.ts. If the CSS and the tokens ever disagree, the integrity test fails before this page can show it.

--text-label
Uppercase micro-labels clamp(11px, 10.63px + 0.1vw, 12px)
--text-xs
Meta, captions, fact rows clamp(12px, 11.63px + 0.1vw, 13px)
--text-sm
UI: links, buttons, the dock, the nav clamp(14px, 13.63px + 0.1vw, 15px)
--text-body
Running text clamp(16px, 15.26px + 0.19vw, 18px)
--text-lede
Opening paragraph, standfirst clamp(19px, 17.14px + 0.48vw, 24px)
--text-title
Section titles clamp(22px, 18.29px + 0.95vw, 32px)
--text-head
Page-level headings clamp(26px, 19.31px + 1.71vw, 44px)

Display type is deliberately absent. The case h1, the Statement, the home's marquee and the closing address are sized from the width they have to fill, and a step would only fight that. The rule: if type fills a measure, solve it from the measure; if it is read, take a step.

Display
clamp(40px, min(11vw, (100svh − 300px) / 3.46), 200px) · 600 · lh 1 · uppercase index.astro, home hero only
Page title
clamp(60px, 14vw, 220px) · 600 · lh 0.84 writing/index.astro, styleguide.astro
Case title
clamp(28px, (100vw − 2·gutter) / 9.14, 160px) · 600 · lh 0.86 · uppercase work/[…].astro, sized off the measure so the longest word never breaks
Case statement
clamp(30px, 1.18rem + 2.86vw, 60px) · 600 · lh 1.02 case/Statement.astro, the working section head
Case statement · display
clamp(40px, (100vw − 2·gutter) / 10.5, 160px) · 600 · lh 0.86 case/Statement.astro, reserved for a case's two or three turns
Article title
clamp(40px, 5.4vw, 60px) · 600 · lh 0.92 prose.css h1
Section title
clamp(22px, 2.6vw, 32px) · 600 index.astro, work/[…].astro (this page rounds to 24px)
Lede · hero
clamp(18px, 2.1vw, 26px) · 400 · Neco index.astro, the home hero only
Lede · page
clamp(16px, 1.8vw, 20px) · 400 · Neco writing/index.astro, styleguide.astro
Body
18px · 400 · Neco · lh 1.72 · 70ch prose.css
Meta
12–14px · 400 everywhere

General Sans for structure, labels and titles.

Neco for reading, and only that.

03

Space & radii

Dense content needs air to stay readable, so the rhythm is generous and even. Containers get large soft corners and everything else stays square.

8
16
24
32
48
64
96

The editorial ladder the long-form pages compose with. Every step is solved for 390px to 1440px and held outside it. All of them follow the width, because a viewport-height term would make the same page breathe differently on a laptop and a monitor.

--space-2xs
--space-xs
--space-sm
--space-md
--space-lg
--space-xl
--space-2xl
--space-3xl
--radius-sm 16px
--radius-md 20px
--radius-lg 24px
--radius-xl 28px
--radius-pill 999px
04

Motion

Motion marks what you can act on, what is still loading and where you are going, and does nothing more. The ink fill is every hover, with a short soft ease and no overshoot. The bar is the site header opening to carry a case's facts once they scroll under it. The sweep is an image's placeholder while its pixels arrive. The page is the one clock a route change runs on.

Hover me, this is the fill 200ms ease
Every interactive surface fills, and does nothing else 200ms ease

--motion-bar (280ms cubic-bezier(0.2, 0, 0, 1)) moves a surface rather than colouring one, so it is longer and decelerates. It animates transform only: the bar's box never changes size, and nothing below it moves.

--motion-exit (200ms cubic-bezier(0.3, 0, 0.8, 0.15)) is the bar's way back: a surface that leaves accelerates and is quicker than it arrived. The agent's panel slides behind its bar on it.

--motion-segmented (150ms ease) is the one exception: the segmented controls in the topbar slide their thumb. Everything else fills.

--motion-breathe (1600ms ease-in-out) is the beat of the one wait: an image's placeholder, a --stage slab in the image's exact box (with the corners and the 1px hairline a framed export bakes in), that a soft band of --bg sweeps across and rests, once a beat, until the pixels arrive; then the slab fades out on --motion-fill. Transform only, and only while the slab is near the screen. Never interactive, never on text, never in client colour; a still slab under reduced motion. The slab, and the sweep crossing it:

--stage rest
--bg the sweep

--motion-page (400ms cubic-bezier(0.2, 0, 0, 1)) is the clock a route change runs on: the bar's curve, held longer, because a page moves a box across the whole viewport (a case rises from the home's list to the bar as one solid sheet). Everything that morphs between two pages, the header, the frame and its band, runs on it. A morph is retimed property by property, so every group and image of a transition takes it in its two halves, --motion-page-duration (400ms) and --motion-page-ease (cubic-bezier(0.2, 0, 0, 1)).

--motion-page-out (120ms cubic-bezier(0.3, 0, 0.8, 0.15)) is the page's way out, on the exit's curve: the page you leave fades first and fast, then the next fades in on --motion-bar, ending with the clock. One page at a time, paper between them, never two at once. Under reduced motion a route change is a cut.

05

The container

The system has a single box: a 1.5px edge, a large radius and 1px hairlines inside. The work index, the facts grid, the metrics and the detail panel all use it, which keeps attention on what's inside.

1.5px solid var(--edge)border
var(--radius-lg)24px
1px solid var(--line) between rowsdivider
06

Fonts

Metric-matched fallbacks hold the layout while the webfont loads, so the page doesn't jump when it arrives.

Sans
'General Sans', 'General Sans Fallback', 'General Sans Android Fallback', system-ui, -apple-system, sans-serif
Serif
'Neco', 'Neco Fallback', 'Neco Android Fallback', Georgia, 'Times New Roman', serif
Mono
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace System stack. No mono webfont is loaded.
07

Icons

None of the site's fonts has an arrow, so the marks are drawn as if the font had them: the font's own strokes, mitred corners, square ends, sized in em.

Horizontal strokes take General Sans's hyphen, vertical ones the stem of its I, diagonals the mean, all read off the shipped woff2 at each weight. A mark sits on the hyphen's height and follows the type it is set in. A company's mark is solid, the way its owner draws it; a generic one is drawn in the fonts' stroke. The corner arrow means the link leaves the site; inside it, arrows point on.

Direction
arrow-left arrow-right arrow-up arrow-up-right
Controls
close expand contract open-full compose chevron agent
Actions
mail document linkedin
Platforms
ios android windows web design-system
In the line
Back Next 0 to 1 400 · 62/75 Back Next 0 to 1 500 · 91/110 Back Next 0 to 1 600 · 124/152 Back Next 0 to 1 700 · 154/189
08

Prose

Long text is what this system is for. Everything Markdown emits is styled once in prose.css, and this section is real Markdown rendered through .brutal-prose.

Body copy is Neco, the serif, at 18px on a 1.72 line, for long-form only. Every note on this site renders through this one class, so what you see here is what a post looks like.

Headings carve the page

An h2 earns a rule above it. That’s the only structural furniture in the system: no boxes, no bars, no accent. Bold switches to the sans deliberately, so emphasis reads as a change of voice rather than weight.

Smaller headings just get smaller

And they stay in the sans, tight-tracked, so a scanned page reads as an outline.

  • Unordered lists get a dash, not a bullet
  • The dash is a 7px rule in the gutter, set on the first line’s baseline
  • Nesting works, but if you need it, question the writing
  1. Ordered lists number in the gutter, right-aligned
  2. Tabular figures, so the column stays true past nine
  3. The numerals are muted because they’re structure

A quote sits behind a 2px rule and speaks in the ink colour, one size up. Whoever said it

Inline code gets the stage colour and a hairline. Links are underlined at a distance and the underline inks in on hover, the same gesture as everything else.

// Code blocks stay ink in both themes. Shiki needs a fixed ground, and a block that
// inverts with the page is a block you can't read a diff in.
export const place = (layer: Layer, at: number): Pt => ({
  x: CX + RING[layer].rx * Math.sin((at * Math.PI) / 180),
  y: CY - RING[layer].ry * Math.cos((at * Math.PI) / 180),
});
TokenRoleNotes
--inkPrimary text and fillThe one ink
--paperThe pageWarm, not white
--edgeThe 1.5px container borderEvery box on the site

Rules are 1.5px and get 48px of air. Em dashes — like this one — are set tight.

Pedro's agent

Expand chat

For real answers, email pedro@pedrosantanna.com