1 - Colour
Three 12-step scales in two modes, their step roles, and the contrast rules that govern them.
The palette is three scales — midnight, orange, and slate — each
expressed as a 12-step ramp in a light and a dark variant. The step numbering
follows the Radix convention, which assigns every step a role. Using a step
outside its role is the single most common way to break the system.
Core colours
These are the named aliases most projects reach for first. They are shortcuts
into the scales, not a separate palette.
Primary
#1d3352
Text, headings, primary surfaces
Primary Light
#2b4d78
Hover states, dark-mode primary
Primary Dark
#132440
Dark backgrounds, navbar, code blocks
Accent
#E05232
CTAs, highlights, active states
Accent Light
#ea7558
Accent hover on dark, syntax strings
Accent Dark
#b84228
Accent pressed state
Accent Solid
#cc4528
Fill for solid controls with white text (4.72:1)
Secondary
#546a82
Supporting text, borders
Step roles
Every scale uses the same twelve roles in the same order:
| Steps | Role | Used for |
|---|
| 1–2 | App and subtle backgrounds | Page and section surfaces |
| 3–5 | Element backgrounds | Component fills, hover, active |
| 6–8 | Borders | Subtle, default, and strong borders |
| 9–10 | Solid | Solid fills and their hover state |
| 11–12 | Text | Low-emphasis and high-emphasis text |
Only 11 and 12 are text steps
Steps 8, 9, and 10 are border and solid-surface roles. They are not held to text
contrast thresholds and must not be used for body text. If you need dimmer text
than step 11, define a dedicated token and verify its contrast — see
the code-comment token for a worked
example.
Midnight
Midnight
light · step 9 #1d3352
Midnight
dark · step 9 #1d3352
Midnight is the primary. It carries structure, text, and the calm end of the
system. Step 9 (#1d3352) is the brand primary and is identical in both modes.
Orange
Orange
light · step 9 #E05232
Orange
dark · step 9 #E05232
Orange is the accent. It marks the primary action, the active state, and little
else. Step 9 (#E05232) is constant across modes.
Do
Use orange for the single most important action in a view, active navigation
state, and focus emphasis.
Don't
Use orange for large background fills, body text, or more than one competing
call to action on the same screen.
Slate
Slate
light · step 9 #546a82
Slate
dark · step 9 #546a82
Slate is the secondary — supporting text, borders, and neutral surfaces. Step 9
(#546a82) is the brand secondary and is constant across modes.
Terminal
The three scales cover every surface that has a light mode. A terminal does not:
it has one surface, it is dark, and every colour in it is measured against that
one background. It also needs six hues where the interface needs three, because
programs have been writing to sixteen ANSI slots since long before this system
existed.
So the terminal palette is a fourth member of the system rather than a fifth
scale — a fixed sixteen-slot palette plus its chrome, derived from the ramps and
measured against midnight-dark-1.
Read the provenance column carefully. The bright ramp is the brand: where a
hue already exists in the system, the bright slot takes that step verbatim. The
normal ramp has no brand equivalent — the scales define one value per
semantic role, not a dim and a bright — so each normal slot is its bright
counterpart darkened until it reads a step back while still clearing the floor.
Magenta and cyan exist in neither half of the brand. They are here because a
terminal requires them, and nowhere else.
A terminal value is not a brand value
#e55b5b is the terminal’s red. It is not $danger, which is #a8261c.
The normal ramp exists to fill ANSI slots and is measured only against the
terminal surface; using one of its values in the interface puts an unmeasured
colour on an unrelated background.
Terminal chrome
The accent gets no ANSI slot, because it is not semantic — it marks where you
are. That, and the surfaces around the sixteen, live here.
Every non-background value clears 4.5:1 against the surface; the measured floor
is 4.95:1. ANSI 0 bright is the one deliberate exception — programs use it for
box drawing and rules, not for text.
Configuration for tmux, WezTerm, Kitty, Ghostty, iTerm2, and Zellij is on the
Terminal theming page.
Contrast rules
- Never use pure
#000 or #fff as text. Use step 12 of the relevant
scale: #142438 on light, #c5daf0 on dark. - Step 9 is constant across modes. The solid accent does not shift when the
theme changes.
- Body text targets 4.5:1, large text (≥24px, or ≥18.66px bold) targets 3:1.
- Verify against the actual surface. A step that passes on the app
background may fail on an elevated panel.
Where an identity colour cannot carry text
Being the brand colour does not make a value a legible background. White on
orange-9 (#E05232) measures 3.87:1 — fine as a mark or a border, but
below the floor for button labels. Rather than dilute the accent, the system
adds a separate fill for that job:
| Token | Hex | With white text |
|---|
--color-accent | #E05232 | 3.87:1 — identity only, not for text |
--color-accent-solid | #cc4528 | 4.72:1 — solid controls |
--color-accent-dark | #b84228 | 5.46:1 — hover and pressed |
The same principle produced the
code-comment token: when no existing
step can do the job accessibly, name a new one rather than misuse a step.
Semantic colours are mode-specific
The callout hues are tuned for dark text on tinted light backgrounds. Used
as foregrounds on the dark app surface they fall below AA, so dark mode has its
own set:
| Role | Light | Dark | On #0e1720 |
|---|
| Success | #2f7d65 | #6cc090 | 3.65:1 → 8.24:1 |
| Warning | #8b6508 | #e0a92a | 3.41:1 → 8.50:1 |
| Danger | #a8261c | #f08b80 | 2.55:1 → 7.49:1 |
| Info | #3a5a82 | #8aacc8 | 2.55:1 → 7.59:1 |
Data visualisation
A chart palette introduces no new colours. It is a set of rules for which
existing steps may sit beside each other in a plot, and — more usefully — for
when colour stops being the right tool.
Categorical: three series
| Series | Token | Hex | On white |
|---|
| 1 | --midnight-9 | #1d3352 | 12.75:1 |
| 2 | --orange-9 | #E05232 | 3.87:1 |
| 3 | --slate-9 | #546a82 | 5.58:1 |
One step-9 solid per family, in that order. All three clear the 3:1 non-text
contrast floor against a white plot area, so a bar or a line is visible
without a border.
Assign them in order and keep the assignment stable across every chart in a
deck or a dashboard: if midnight is “cloud” on slide four, it is “cloud” on
slide nine. A series that changes colour between charts costs the reader more
than a fourth series would have gained them.
Three-series grouped bars — the whole categorical palette
Orange and slate differ in hue, not in value
orange-9 against slate-9 measures 1.44:1. On screen they are easy to
tell apart — orange against blue-grey is also one of the safest pairs for the
common colour-vision deficiencies. Printed in greyscale, or on a projector with
the colour turned down, they merge.
So when exactly two series are being compared, use midnight-9 and orange-9
(3.29:1) and leave slate for the third. And direct-label every series — the
legend is the fallback, not the mechanism.
There is no fourth series
The palette stops at three, and extending it is the wrong fix. The step-6 tier
is not an option: midnight-6 and slate-6 measure 1.03:1 against each
other — the same colour, for practical purposes — and all three step-6 values
sit at 1.8–2.0:1 against white, below the 3:1 floor for a mark you have to see.
When a chart has more than three categories, one of these is the answer:
- Group the tail. Rank the categories and collapse everything past the third
into “Other”. If the fourth is genuinely interesting, it is the subject of its
own chart.
- Small multiples. One chart per category, same axes, same scale. Reading
eight small charts is faster than decoding an eight-colour legend.
- Direct labelling with one highlight. Draw every series in
slate-7, draw
the one being discussed in orange-9, and label it in place. This is the
house style for a line chart in a
deck — one idea per slide holds
for charts too. - Stop using colour. A ranked bar chart in a single colour, sorted by value,
answers “which is biggest” better than any palette does.
Sequential and ordinal scales
A magnitude scale uses one family, steps 3 through 8:
--midnight-3 → --midnight-4 → --midnight-5 → --midnight-6 →
--midnight-7 → --midnight-8
Six levels, stepping evenly in luminance (each 1.06–1.40× its neighbour) — which
is what makes the ramp readable as an ordered scale rather than as six colours.
Step 9 is not the top of that ramp. It is 3.87× darker than step 8, which is
a jump the eye reads as a category boundary rather than one more level. Use it
deliberately for exactly that: a four-bucket choropleth of 3 · 5 · 7 · 9,
where the top bucket is meant to separate itself. Do not append it to a
six-level heatmap.
Steps 3–7 are all below 3:1 against a white plot area, so a sequential fill
needs an edge: give the plot a 1px slate-4 cell grid, or the reader loses the
boundary between a light cell and the page.
For a diverging scale — where the middle is neutral and both ends are extreme —
run midnight-8 → midnight-3 → orange-3 → orange-8, with --midnight-1 at the
midpoint. Never build a diverging scale from success and danger: those hues
carry a judgement, and “below average” is not “wrong”.
Chart furniture
| Element | Value |
|---|
| Axis line, ticks | --slate-5 |
| Grid lines | --slate-3, horizontal only |
| Axis labels, legend | --slate-11, 12px |
| Value labels | --midnight-12, 12px, IBM Plex Mono |
| Plot background | none — the page surface |
| Annotation, callout rule | --orange-9 |
Numbers are set in IBM Plex Mono, right-aligned, for the same reason
table numerics are: digits have
to line up to be compared.
Do
Keep categorical charts to three series and label them directly. Hold a series'
colour constant across a deck. Use one family’s steps 3–8 for magnitude.
Don't
Invent a fourth categorical colour from the step-6 tier, rely on a legend as the
only way to identify a series, append step 9 to a sequential ramp, or build a
diverging scale from the success and danger hues.
Dark mode
Both modes are equally supported. See
Dark mode for the implementation
rules — theme switching, persistence, image treatment, and the always-dark code
surface.
2 - Typography
Three typefaces, three jobs, and a fixed type ramp.
The system uses three typefaces, each with one job. All three are licensed under
the SIL Open Font License 1.1 and served from Google Fonts, so there are no
bundled font binaries to license or redistribute.
The three families
| Family | Weights | Role | Licence |
|---|
| Plus Jakarta Sans | 400 · 500 · 600 · 700 · 800 | Headings, display, buttons, navigation, wordmark | SIL OFL 1.1 |
| Source Sans 3 | 400 · 500 · 600 | Body copy, UI labels, captions | SIL OFL 1.1 |
| IBM Plex Mono | 400 · 500 | Code, terminal output, data | SIL OFL 1.1 |
Loading them:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Source+Sans+3:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
The ramp
Every row below is set in the family, size, weight, and line-height it
documents — this is the live specimen, not a picture of one.
Display · 48/800/1.1
Redesigning work
H1 · 36/700/1.15
Agent-first consulting
H2 · 28/700/1.2
Composable infrastructure
H3 · 22/600/1.25
How the system fits together
H4 · 18/600/1.3
Working in the open
H5 · 15/600/1.35
Supporting detail
Body L · 16/400/1.65
Augmenting people's strengths through composable Cloud, Agile, and Agentic AI infrastructure.
Caption · 12/400/1.5
Figure 1 — pipeline stages and their policy gates.
Overline · 11/600/1.3
SECTION LABEL
Code · 13/400/1.6
createPipeline({ policy: "strict" })
Rules
- Letter-spacing tightens to −0.3px above 20px. Large type set at default
tracking reads loose and unresolved.
- Overline is a label, not a heading. 11px, 600, uppercase, 0.08em tracking.
Use it above a heading, never as one.
- Body copy is 16px / 1.65. This is the reading size for documentation and
long-form prose. Do not set body text below 14px.
- Never set body copy in Plus Jakarta Sans, and never set headings in
Source Sans 3. The split is what makes the voice recognisable.
Do
Pair an overline with an H2 to label a section. Keep the ramp intact — skip
sizes rather than inventing intermediate ones.
Don't
Introduce a fourth typeface, use a weight outside the listed set, or fake a
weight with font-synthesis.
3 - Space, shape, and motion
The 4px spacing base, the radius and elevation ladders, and the motion tokens.
Spacing
A 4px base with a nine-step scale. Every margin, padding, and gap resolves to
one of these values.
| Token | Value | Typical use |
|---|
--space-1 | 4px | Icon-to-label gaps |
--space-2 | 8px | Tight component padding |
--space-3 | 12px | Control padding |
--space-4 | 16px | Default element spacing |
--space-5 | 24px | Card padding, paragraph rhythm |
--space-6 | 32px | Component separation |
--space-7 | 48px | Sub-section separation |
--space-8 | 64px | Section separation |
--space-9 | 96px | Page-level bands |
Content sits inside a 1100px measure. Wider viewports gain margin, not
line length.
Radius
| Token | Value | Applied to |
|---|
--radius-sm | 3px | Tags, chips, small indicators |
--radius-md | 6px | Buttons, inputs, code blocks |
--radius-lg | 9px | Cards, panels |
--radius-xl | 13px | Large panels, modals |
--radius-full | 9999px | Pills, avatars |
Radius is a signal of scale: the larger the surface, the larger the radius.
Mixing radii on nested surfaces reads as a mistake — a 6px control inside a 9px
card is correct; a 13px control inside a 6px card is not.
Elevation
Four levels. Shadows are soft and low-contrast; the system leans on borders and
surface tint before it reaches for shadow.
| Token | Value | Applied to |
|---|
--shadow-0 | none | Flat surfaces, default |
--shadow-1 | 0 1px 3px rgba(0,0,0,0.06) | Cards at rest |
--shadow-2 | 0 4px 12px rgba(0,0,0,0.08) | Hover, dropdowns |
--shadow-3 | 0 8px 24px rgba(0,0,0,0.12) | Modals, popovers |
In dark mode, elevation is expressed by lightening the surface rather than
deepening the shadow — a shadow on a near-black background is invisible.
Motion
Things slide into place. They do not bounce.
Durations
| Token | Value | Use |
|---|
--duration-micro | 100ms | Colour and opacity changes |
--duration-standard | 200ms | Hover, focus, small moves |
--duration-expand | 300ms | Height and width changes, accordions |
--duration-page | 400ms | Route and view transitions |
Easing
| Token | Value | Use |
|---|
--ease-out | cubic-bezier(0.33, 1, 0.68, 1) | Entering — arriving on screen |
--ease-in | cubic-bezier(0.32, 0, 0.67, 0) | Exiting — leaving screen |
Radius ladder — 3 / 6 / 9 / 13 / full
sm 3px
md 6px
lg 9px
xl 13px
full
Elevation — shadow-0 through shadow-3
shadow-0
shadow-1
shadow-2
shadow-3
Motion — slides into place, never bounces
200ms · ease-out — entering
100ms · colour and opacity
Motion rules
- All animation wraps in
@media (prefers-reduced-motion: no-preference), or is
disabled under prefers-reduced-motion: reduce. - CSS-only for HTML. For React, use Framer Motion with these same timing values.
- Never animate text character-by-character.
- Page transitions cap at 400ms.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Do
Use --ease-out for anything appearing and --ease-in for anything leaving.
Keep durations on the ladder.
Don't
Add spring or bounce easing, animate layout-shifting properties, or exceed 400ms
for any single transition.
4 - Responsive
The four breakpoints, how the 12-column grid collapses, touch-target sizes, and the mobile navigation pattern.
The rest of the foundations describe values that do not change with viewport
width. This page describes the four points at which the layout does.
Breakpoints
Four breakpoints, declared in
brand/tokens/variables.css and
normative here:
| Name | Min width | The layout it describes |
|---|
sm | 640px | Large phone, landscape phone |
md | 768px | Small tablet — the first two-column layout |
lg | 1024px | Tablet landscape, small laptop — sidebars appear |
xl | 1280px | Desktop — the 1100px measure is fully margined |
Below sm is not a breakpoint; it is the base. Write the narrow layout
first and add width with min-width queries, so an unstyled or unsupported
viewport gets the single-column layout rather than a clipped desktop one.
/* Base — single column, 360px and up. */
.panel-row { display: grid; gap: var(--space-4); }
@media (min-width: 768px) {
.panel-row { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.panel-row { grid-template-columns: 1.5fr 1fr; }
}
Breakpoint ruler — base · 640 · 768 · 1024 · 1280
base
1 col
640
1 col
768
2 col
1024
+ sidebar
1280
margined
How the grid collapses
The grid is 12 columns inside the
1100px measure. It does
not stay twelve columns all the way down — it resolves to four column counts:
| From | Columns | Gutter | Page padding |
|---|
| base | 4 | 16px | 16px |
md 768px | 8 | 16px | 24px |
lg 1024px | 12 | 24px | 32px |
xl 1280px | 12 | 24px | auto — the measure caps at 1100px |
Below md, every multi-column region becomes one column. There is no
two-column layout on a phone: two 160px columns are two unreadable columns.
Regions stack in source order, so the DOM must already be in reading order
— do not rely on order or grid-area to fix a sequence that is wrong in the
markup, because that breaks the focus and reading
order.
Spacing steps down one rung when the grid collapses: a --space-6 (32px)
section gap on desktop becomes --space-5 (24px) below md. Radius, type
scale, and border weights do not change — a card is a 9px card at every
width.
The 1100px measure is a maximum, not a target
Wider viewports gain margin, not line length. At xl the content stays 1100px
wide and centres; it does not grow to fill a 1920px display. This is the same
rule the spacing page states, restated here because it is the most common thing
a responsive rewrite breaks.
Touch targets
44×44px minimum for anything tappable, at every viewport — the floor is a
finger, not a breakpoint.
The default control heights are 32 / 40 / 48px, so the sm and md sizes are
below the floor on their own. Two ways to resolve it, in order of
preference:
- Grow the hit area, not the control. Keep the 40px visual button and give
it a transparent 2px vertical extension, or wrap it in a 44px-tall row. The
measurement stays normative; the target clears the floor.
- Use
lg (48px) on touch-primary surfaces. Correct for the primary action
on a phone, where a 40px button next to a 48px one reads as demoted.
Never shrink to sm (32px) for a touch target. Spacing between adjacent targets
is at least --space-2 (8px), so a mis-tap does not fire the neighbour.
/* Preferred: 40px control, 44px target. */
.pj-btn--md {
min-height: 40px;
position: relative;
}
.pj-btn--md::after {
content: "";
position: absolute;
inset: -2px 0; /* 40 + 2 + 2 = 44 */
}
Mobile navigation
Use a bottom tab bar for application surfaces. Use a top drawer for
documentation and marketing surfaces. The choice follows the shape of the
navigation, not the shape of the device:
- An application has a small, flat set of destinations — the
dashboard example has five.
Five or fewer flat destinations fit a tab bar, which keeps the current
location permanently visible, sits in the thumb arc, and costs no taps to
reach. That is worth the 56px of permanent screen it occupies.
- Documentation has a deep, nested tree. A tab bar cannot express it, and
flattening the tree to fit one is worse than a drawer. A drawer behind a
labelled disclosure control costs one tap and can show the whole hierarchy.
The failure mode is a tab bar with six or more items, or one that hides the
primary destination behind “More”. If the destinations do not fit, the surface
is a drawer surface.
Both patterns carry the same obligations: the current destination is marked with
an accent underline or fill plus the label, never colour alone; the drawer
traps focus while open, closes on Esc, and returns focus to its trigger — the
same contract as a modal.
Bottom tab bar — 5 destinations, current marked with fill and label
screen content
Overview
Agents
Alerts
Account
Tables, code, and diagrams
Three content types cannot reflow, and each has one answer:
- Tables scroll horizontally inside their own container with the first
column pinned — never the page. See wide
tables.
- Code blocks scroll horizontally. Do not soft-wrap code: a wrapped line
changes what the code appears to say.
- Diagrams get a scrollable container or a re-drawn narrow variant. Do not
scale a diagram down until its labels are unreadable — an 8px label is not a
responsive diagram, it is a broken one.
Worked example
Mobile onboarding —
three screens in a device frame: an onboarding panel on midnight, an engagement
list of stacked cards, and a finding-review screen with two side-by-side
actions. It is the dashboard
below md: same tokens, same components, one column, tab bar instead of
sidebar.
Do
Write the base layout first and add width with min-width queries. Put regions
in the DOM in reading order. Give every tappable thing a 44px target.
Don't
Keep two columns below 768px, reorder regions with CSS to fix source order,
scale a diagram until its labels are illegible, or let a wide table scroll the
whole page.