Harbor is the StackShip visual language. This page is the specification: every token, every value, and the rules that decide where each one is allowed to appear. If you are building a new page, a new panel, or a new component, build it from this page and nothing else.
The source of truth for the values below is apps/web/app/globals.css. The docs
site restates the same palette in apps/docs/src/styles/globals.css using
Nimbus --nb-* custom properties. When a value changes, it changes in both
files in the same commit.
Where Harbor comes from
StackShip is a system of record for infrastructure change. The register it needs is a Bloomberg terminal or an aviation checklist, not a consumer SaaS landing page. Harbor takes its material from port and freight infrastructure:
- Ink — the deep navy of a hull and of a terminal window. Carries hero bands, footers, plan output, and the docs dark mode.
- Steel — cool neutrals for type, hairlines, and quiet fills. Steel does the majority of the work on every screen.
- Limestone — the near-white canvas panels sit on. Warmer than a blue-gray page and quieter than pure white.
- Signal — one safety orange, the color of a high-visibility vest and a container crane. Rare by construction, so it always means “act here”.
Density is the point. A compact panel carrying twelve facts beats a large card carrying two.
Two implementations, one contract
The same tokens reach the two surfaces through different mechanisms. Neither app may define a palette of its own.
| Surface | File | Mechanism | Modes |
|---|---|---|---|
apps/web dashboard and marketing |
apps/web/app/globals.css |
Absolute ramps on :root, mapped to Tailwind utilities in @theme inline |
One light family, plus ink bands placed deliberately. No OS dark mode. |
apps/docs documentation |
apps/docs/src/styles/globals.css |
Absolute ramps as --nb-*, semantic --nb-* properties read by Nimbus internals, re-exposed to Tailwind in @theme |
Light and dark. Dark mode is the ink family, switched on [data-mode="dark"]. |
Both files declare the identical ramps with identical hex values. The divergences are deliberate and listed in docs-specific mappings.
The three palette rules
These are not style preferences. A change that breaks one of them is a regression.
1. Signal orange is interface only
Signal marks intent to act and nothing else. It is allowed on:
- primary buttons and primary links,
- the active item in a navigation rail or sidebar,
- focus rings,
- eyebrow labels,
- the brand mark and lockup,
- at most one accent rule per band.
Signal never encodes system state. A run is never orange. A metric is never orange. A chart series is never orange — that is why the chart ramp deliberately excludes it. If orange appears twice in a viewport for two different reasons, one of them is wrong.
2. State is semantic, and always labelled
System state is carried by exactly four families — success, info, attention, danger — and every state indicator carries a text label plus an icon. Color is never the only channel.
| Condition | Family | Label examples |
|---|---|---|
| Healthy, applied, policy passed | success |
Healthy, Applied, Passed |
| Running, queued, planning, informational | info |
Running, Queued, Planning |
| Awaiting approval or operator recovery | attention |
Awaiting approval, Needs recovery |
| Failed, policy denied, errored, destructive | danger |
Failed, Denied, Errored |
3. No new color literals
Components use tokens. No #hex, rgb(), oklch(), or hsl() in a
component file. Opacity modifiers on tokens (bg-white/5, border-white/10,
text-white/60) and SVG geometry fills that reference tokens are fine. If you
need a value that does not exist, the answer is a discussion about the ramp,
not a literal.
Surface families
There are exactly two surface families. There is never a third.
Light — reading and operating
| Role | Utility | Token |
|---|---|---|
| Canvas | bg-background |
limestone #f7f8fa |
| Panel | bg-card |
#ffffff |
| Hairline | border-border |
steel-100 #e6ebf1 |
| Stronger hairline | border-border-strong |
steel-200 #c9d4e0 |
| Heading and body | text-foreground |
ink-900 #0a1622 |
| Secondary text | text-muted-foreground |
steel-500 #5c7290 |
| Quiet fill | bg-muted, bg-secondary, bg-accent |
steel-50 #f0f4f8 |
Ink — hero bands, terminals, plan output
| Role | Utility | Token |
|---|---|---|
| Band | bg-ink-950 or bg-ink-900 |
#050b14, #0a1622 |
| Panel on a band | bg-ink-850 |
#0e1d2c |
| Raised row on a band | bg-ink-800 |
#13263a |
| Hairline | border-ink-700, border-ink-800 |
#1c3049, #13263a |
| Heading | text-white |
— |
| Secondary text | text-steel-300, text-steel-400 |
#a6b5c8, #7d91ac |
| Tertiary detail | text-white/60 |
— |
| State | the *-on-ink variants |
see semantic state |
A white panel that sits on top of an ink band uses shadow-ink. Mixing the two
families inside one panel — a limestone fill inside an ink card, steel-500 body
copy on navy — produces the muddy third family the rules exist to prevent.
Token contract
Ink ramp
Navy surfaces. Six stops, all absolute.
| Token | Value | Use |
|---|---|---|
ink-950 |
#050b14 |
Deepest band: hero, footer, terminal chrome. The docs dark canvas. |
ink-900 |
#0a1622 |
foreground type on light surfaces. Secondary ink band. The docs dark panel step-up. |
ink-850 |
#0e1d2c |
Panel sitting on an ink band. The docs dark card. |
ink-800 |
#13263a |
Raised row, hovered row, or table header on ink. The docs dark muted fill. |
ink-700 |
#1c3049 |
Default hairline on ink. Scrollbar thumb on ink. |
ink-600 |
#27405c |
Stronger hairline on ink. Scrollbar thumb hover on ink. |
Steel ramp and limestone
Cool neutrals. Steel carries type, hairlines, and quiet fills.
| Token | Value | Use |
|---|---|---|
steel-600 |
#435a79 |
Strongest steel. Column labels and metadata on light that need more weight than muted-foreground. |
steel-500 |
#5c7290 |
muted-foreground. The lightest steel permitted for body copy on white or limestone. |
steel-400 |
#7d91ac |
Secondary text on ink only. Never body copy on a light surface. |
steel-300 |
#a6b5c8 |
Secondary text on ink. Scrollbar thumb hover on light. |
steel-200 |
#c9d4e0 |
border-strong, input borders, harbor-rule, scrollbar thumb on light. |
steel-100 |
#e6ebf1 |
The default dashboard hairline (border, sidebar-border). |
steel-50 |
#f0f4f8 |
Quiet fills: muted, secondary, accent, sidebar-accent. |
limestone |
#f7f8fa |
The application and marketing canvas (background). |
Signal ramp
The brand accent. Interface only — see rule 1.
| Token | Value | Use |
|---|---|---|
signal-600 |
#bd4b10 |
primary-hover on light. Pressed state. |
signal-500 |
#e2601a |
primary, ring, sidebar-primary, the brand mark, the selection tint, harbor-glow. |
signal-400 |
#f0803f |
Hover for a primary action on an ink surface, where deepening would disappear. Eyebrow labels on ink. |
signal-300 |
#f7ab7c |
Quiet accent detail on ink: an accent rule, a mark stroke. |
signal-100 |
#fdeee4 |
Selected-row tint and quiet accent fill on light. |
Semantic state
Every family has three variants: the saturated value for text, icons, and
borders on light; a -soft tint for fills on light; and an -on-ink lift for
navy surfaces.
| Family | Base | Soft | On ink | Meaning |
|---|---|---|---|---|
success |
#0f7a4a |
#e8f5ee |
#4fd394 |
Healthy, applied, policy passed |
info |
#1f5fa8 |
#eaf1fa |
#6fb0f5 |
Running, queued, informational |
attention |
#8f5a0b |
#fdf3e3 |
#e9b45f |
Awaiting approval, needs recovery |
danger |
#b4231c |
#fdecea |
#f58b83 |
Failed, denied, destructive |
Utilities are text-success / bg-success-soft / text-success-on-ink and the
equivalents for the other three. The base values are chosen to clear 4.5:1 on
limestone and white; the -on-ink values clear it on ink-950 and ink-900.
Do not use a base value on ink, and do not use an -on-ink value on white.
A destructive control needs the label, the icon, and a confirmation step.
danger on its own is not a guardrail.
Chart ramp
Data visualization gets its own ramp, deliberately excluding signal so a chart series can never be mistaken for an action.
| Token | Value | Notes |
|---|---|---|
chart-1 |
#1f5fa8 |
Same blue as info |
chart-2 |
#0e7c86 |
Teal |
chart-3 |
#4c4fa6 |
Indigo |
chart-4 |
#8f5a0b |
Same amber as attention |
chart-5 |
#5c7290 |
steel-500, for the “other” or baseline series |
When a chart encodes state rather than an arbitrary category — a run outcome breakdown, for example — use the semantic families instead, so the chart agrees with the tables next to it.
shadcn semantic mappings
These are the tokens shadcn/ui primitives read. Use the semantic name in components, not the ramp stop it resolves to.
| Token | Resolves to | Value | Use |
|---|---|---|---|
--radius |
— | 0.5rem |
The base every radius is derived from |
--background |
limestone | #f7f8fa |
Application and page canvas |
--foreground |
ink-900 | #0a1622 |
Headings and body copy |
--card |
— | #ffffff |
Panels, tables, dialogs, forms |
--card-foreground |
ink-900 | #0a1622 |
Type inside a panel |
--popover |
— | #ffffff |
Menus, popovers, command palette |
--popover-foreground |
ink-900 | #0a1622 |
Type inside a popover |
--primary |
signal-500 | #e2601a |
Primary action fill |
--primary-foreground |
ink-950 | #050b14 |
High-contrast type on a primary action |
--primary-hover |
signal-600 | #bd4b10 |
Primary action hover and pressed |
--secondary |
steel-50 | #f0f4f8 |
Secondary button fill, quiet surface |
--secondary-foreground |
— | #2c4260 |
Type on a secondary surface |
--muted |
steel-50 | #f0f4f8 |
Table headers, quiet fills, inactive tracks |
--muted-foreground |
steel-500 | #5c7290 |
Metadata, descriptions, secondary copy |
--accent |
steel-50 | #f0f4f8 |
Hover fill for rows and menu items |
--accent-foreground |
— | #2c4260 |
Type on an accent fill |
--destructive |
state-danger |
#b4231c |
Destructive control |
--border |
steel-100 | #e6ebf1 |
Default hairline |
--border-strong |
steel-200 | #c9d4e0 |
Hairline that must read on limestone |
--input |
steel-200 | #c9d4e0 |
Field borders |
--ring |
signal-500 | #e2601a |
Focus ring |
--secondary-foreground and --accent-foreground resolve to #2c4260, an ink
mid-tone that exists only as a semantic value. Do not copy that literal into a
component; use text-secondary-foreground or text-accent-foreground.
Control plane shell tokens:
| Token | Resolves to | Value |
|---|---|---|
--sidebar |
— | #ffffff |
--sidebar-foreground |
ink-900 | #0a1622 |
--sidebar-primary |
signal-500 | #e2601a |
--sidebar-primary-foreground |
ink-950 | #050b14 |
--sidebar-accent |
steel-50 | #f0f4f8 |
--sidebar-accent-foreground |
— | #2c4260 |
--sidebar-border |
steel-100 | #e6ebf1 |
--sidebar-ring |
signal-500 | #e2601a |
Docs-specific mappings
The docs site adds a small number of properties the dashboard does not need, and renames one family to match what Nimbus internals expect. Values are unchanged.
| Docs property | Equivalent | Note |
|---|---|---|
--nb-warning, --nb-warning-muted |
attention, attention-soft |
Nimbus calls the family “warning”. The absolute value is still --nb-state-attention. |
--nb-success-on-dark |
success-on-ink |
Alias kept for the shipped Nimbus components. |
--nb-terminal, --nb-terminal-foreground |
ink band | ink-950 in light mode, ink-900 in dark mode, so a dark panel stays visible against a dark canvas. |
--nb-selected |
signal-100 | Selected sidebar item. In dark mode it becomes signal-500 mixed 20% into ink-950. |
--nb-ring |
signal at 45–50% alpha | rgb(226 96 26 / 0.45) light, rgb(240 128 63 / 0.5) dark. |
--nb-border |
steel-200 in light mode | One ramp step stronger than the dashboard’s border, because docs rules sit on limestone as often as on white. |
Docs layout measurements, also tokens:
| Property | Value | Meaning |
|---|---|---|
--nb-header-height |
6rem |
3.5rem brand row plus a 2.5rem section tab strip. Constant across breakpoints; every sticky column hangs from it. |
--nb-sidebar-width |
17.25rem |
Navigation rail |
--nb-toc-width |
18rem |
Table of contents column |
--nb-content-max |
44rem, 48rem at ≥1536px |
Reading measure, roughly 90 characters at 15px |
Typography
Three faces, each with one job. The dashboard loads them through next/font
(apps/web/app/layout.tsx); the docs site self-hosts them with @fontsource.
| Face | Token | Carries |
|---|---|---|
| IBM Plex Sans | font-display |
Every heading, h1 through h4, and marketing display type |
| Inter | font-sans |
Interface, prose, labels, everything not a heading or an identifier |
| IBM Plex Mono | font-mono |
Identifiers, plan output, code |
Both apps set font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss03"
on the document so Inter’s single-storey glyphs stay legible in dense tables.
Tracking is load-bearing
IBM Plex Sans sets wide and reads soft at display sizes. Negative tracking is
part of the specification, not a flourish. The dashboard applies
letter-spacing: -0.022em and text-wrap: balance to h1–h4 in the base
layer; the docs site sets per-level values.
Type scale
| Role | Dashboard | Docs |
|---|---|---|
| Marketing display | text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.05] |
— |
h1 |
font-display, tracking -0.022em |
2.125rem, weight 600, tracking -0.024em |
h2 |
font-display, tracking -0.022em |
1.375rem, weight 600, tracking -0.02em |
h3 |
font-display, tracking -0.022em |
1.0625rem, weight 600, tracking -0.015em |
h4 |
font-display, tracking -0.022em |
weight 600 |
| Body, marketing | text-[15px] leading-7 |
15px prose measure |
| Body, dense interface | text-sm leading-6 |
text-sm leading-6 |
| Eyebrow | 11px, uppercase, tracking-[0.14em], font-semibold |
same |
| Code block | — | 0.8125rem, line height 1.7 |
Headings are sentence case, always. Never Title Case.
Where mono is mandatory
Not a stylistic option. Use font-mono for:
- run ids —
run_01K3F8PJ2M - commit shas —
8f4b0ac - resource addresses —
module.network.aws_vpc.main - versions and provider constraints —
~> 5.0 - plan output and plan counts —
+12 ~3 −1 - CLI commands —
stackship run watch run_01K3F8PJ2M - endpoint paths —
POST /v1/runs - any number compared down a column
Tables and elements marked data-numeric get font-variant-numeric: tabular-nums from the base layer in both apps, so figures line up without
extra classes. Plan counts use the real minus sign, U+2212 (−), not a hyphen.
Shape
One radius base, --radius: 0.5rem, with five derived steps.
| Utility | Computed | Use |
|---|---|---|
rounded-sm |
calc(0.5rem - 3px) = 5px |
Small tags, badges, inline chips |
rounded-md |
calc(0.5rem - 1px) = 7px |
Controls: buttons, inputs, selects, the brand mark |
rounded-lg |
0.5rem = 8px |
Menus, popovers, docs cards and code blocks |
rounded-xl |
calc(0.5rem + 2px) = 10px |
Panels and cards |
rounded-2xl |
calc(0.5rem + 6px) = 14px |
The largest structural container in a band |
No pill shapes. The only exceptions are avatars and status dots, which are circles.
Elevation
Shadows are cool-tinted, shallow, and never colored. A glow is never applied to a button.
| Utility | Value | Use |
|---|---|---|
shadow-hairline |
0 0 0 1px rgb(10 22 34 / 0.06) |
A border-weight ring where a real border would shift layout |
shadow-panel |
0 1px 2px rgb(10 22 34 / 0.04), 0 1px 1px rgb(10 22 34 / 0.03) |
Resting card or panel |
shadow-raised |
0 2px 4px rgb(10 22 34 / 0.05), 0 8px 20px -12px rgb(10 22 34 / 0.14) |
Hover on an interactive card |
shadow-float |
0 12px 32px -12px rgb(10 22 34 / 0.2), 0 2px 6px rgb(10 22 34 / 0.06) |
Menus, popovers, dialogs, command palette |
shadow-ink |
0 24px 60px -28px rgb(5 11 20 / 0.55) |
A light panel that sits on an ink band |
The docs site exposes the same first three as --nb-shadow-sm, --nb-shadow,
and --nb-shadow-lg, and darkens them in dark mode where a cool tint would be
invisible.
Motion contract
| Property | Value |
|---|---|
| Curve | --ease-harbor = cubic-bezier(0.2, 0.8, 0.2, 1) |
| Duration | 150–250ms for interaction; 500ms for a one-shot entrance |
| Animated properties | transform and opacity only |
Named animations, defined once in globals.css:
| Class | Timing | Use |
|---|---|---|
harbor-rise |
0.5s var(--ease-out-harbor) both |
Entrance: opacity 0 and translateY(10px) to rest |
harbor-caret |
1.1s step-end infinite |
Blinking cursor in a terminal or CLI demo |
harbor-sweep |
2.6s var(--ease-out-harbor) infinite |
A single progress sweep across a running stage |
Not allowed: parallax, autoplaying carousels, scroll-jacking, animated
gradients, more than one harbor-glow per band.
Both apps zero out animation and transition durations under
prefers-reduced-motion: reduce in globals.css. Do not defeat that with
inline styles or JavaScript-driven animation that ignores the query. If an
animation carries meaning, the meaning must also be present in text.
Surface utilities
Hand-written classes that exist in both apps. Reuse them rather than reimplementing the effect.
| Class | Effect |
|---|---|
harbor-blueprint |
Two white hairline grids at 96px and 24px, so an ink band reads as a drawing sheet rather than a gradient |
harbor-blueprint-light |
The same 24px grid in ink, for light sections |
harbor-fade-edges |
Radial mask that fades a texture out toward the edges of its band |
harbor-glow |
One low-opacity signal glow, blurred 64px. At most once per band |
harbor-rule |
Tapered hairline that reads as a technical rule rather than a border |
harbor-scroll-ink |
Steel-on-ink scrollbar for a panel on a navy surface |
harbor-rail |
Docs only. A horizontally scrolling strip with hidden scrollbar and a masked overflow edge |
Any code, terminal, table, or log region that can overflow must remain
keyboard reachable. Give a standalone scroll container role="region",
tabIndex={0}, and a specific accessible name so keyboard and screen-reader
users can identify and scroll the region without a pointer.
Density and layout
The dashboard is an operating surface, not a marketing page.
- A persistent left rail carries organization, project, and workspace hierarchy. The active item is the one place signal appears in the rail.
- The page header keeps the current scope, documentation, and identity controls reachable.
- Overview pages lead with health and run state, then workspace inventory, then recent audit activity.
- Run pages expose durable planning, policy, approval, apply, and terminal states, each with its own state label and icon.
- Dense data lives in tables. Cards carry summaries and bounded workflows. Prefer an information-rich panel over a large empty one.
- Plan output and log streams sit on an ink surface in
font-mono, withharbor-scroll-ink. - Loading, empty, unavailable, and error states are explicit and never represented as invented sample product data.
At narrow widths the rail becomes a sheet, tables drop secondary columns rather than scrolling horizontally, and the run lifecycle becomes a vertical list. Primary actions stay reachable without horizontal scrolling. Long identifiers wrap or truncate inside their owning panel; a monospace workspace, run, state, or commit identifier never creates horizontal page overflow.
Identity surfaces
Sign-in, sign-up, and OAuth consent use the same language as the control plane — they are the first screen a security reviewer sees.
- Pair a single focused form with product and security context. No decorative illustration.
- Keep enterprise SSO visually distinct from consumer login methods, and label it as such.
- Error states are explicit, in
danger, with a label and an icon, and they say what to do next. - OAuth consent explains each scope in plain language, names the client, and accurately describes the token’s tenancy, lifetime, and available revocation behavior. It does not invent settings controls or audit events.
The brand mark is a sanctioned use of signal. apps/web/components/stackship-mark.tsx
exports StackShipGlyph, StackShipMark, and StackShipLockup.
| Export | Props | Notes |
|---|---|---|
StackShipGlyph |
className |
The bare glyph. Decorative uses need aria-hidden. |
StackShipMark |
className, glyphClassName, tone |
tone is "signal" (default), "ink", or "outline". rounded-md, size-8. |
StackShipLockup |
className, onInk, suffix, tone |
Mark plus wordmark. onInk flips the wordmark to white with a steel-300 suffix. |
Use tone="ink" when the lockup sits on a light surface next to a primary
orange action, so the logo does not compete with the button.
The production browser icon is served from /favicon.ico. It uses the same
StackShipGlyph geometry with signal-500 behind an ink-950 glyph; keep the
root metadata and favicon route synchronized when the mark changes.
Accessibility floor
Part of the definition of done, not a later pass.
- Body copy holds at least 4.5:1 against its surface. Concretely: never
text-steel-400body copy on white or limestone, and nevertext-steel-500on ink. Usesteel-500as the lightest text on light surfaces andsteel-300as the lightest on ink. - Every interactive element has a visible focus indicator. The dashboard uses
outline-ring/45from the base layer; the docs site uses a 2px--nb-ringoutline with a 2px offset. Do not remove either. - Icon-only controls carry
aria-label. Decorative SVG carriesaria-hidden. - State is never color alone — label plus icon, every time.
- Real
buttonandaelements. Never a clickablediv. - Heading levels run in order with no skipped levels.
- Tables have real
thelements with a scope. - No emoji, in interface copy or documentation.
shadcn rules
- Generate primitives with the shadcn CLI. They live in
apps/web/components/ui. - Build product components by composing primitives. Do not fork a primitive to change its color.
- Reference semantic utilities —
bg-background,bg-card,text-foreground,border-border,bg-primary,text-muted-foreground— rather than ramp stops, wherever a semantic name exists. Reach forink-*,steel-*, andsignal-*directly only for ink bands and brand moments, where no semantic token applies. - Do not paste in arbitrary component collections, and do not introduce a second token system.
Changing a token
- Edit
apps/web/app/globals.css. It is the source of truth. - Mirror the change in
apps/docs/src/styles/globals.css, including the[data-mode="dark"]block and the@thememapping. - Update the tables on this page. Documentation is part of the definition of done — see the engineering rules.
- Check contrast for both surface families before and after.