Skip to content

Design system

Harbor — the StackShip token contract, surface rules, typography, and accessibility floor for every product surface.

Updated View as Markdown

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 h1h4 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, with harbor-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-400 body copy on white or limestone, and never text-steel-500 on ink. Use steel-500 as the lightest text on light surfaces and steel-300 as the lightest on ink.
  • Every interactive element has a visible focus indicator. The dashboard uses outline-ring/45 from the base layer; the docs site uses a 2px --nb-ring outline with a 2px offset. Do not remove either.
  • Icon-only controls carry aria-label. Decorative SVG carries aria-hidden.
  • State is never color alone — label plus icon, every time.
  • Real button and a elements. Never a clickable div.
  • Heading levels run in order with no skipped levels.
  • Tables have real th elements 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 for ink-*, steel-*, and signal-* 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

  1. Edit apps/web/app/globals.css. It is the source of truth.
  2. Mirror the change in apps/docs/src/styles/globals.css, including the [data-mode="dark"] block and the @theme mapping.
  3. Update the tables on this page. Documentation is part of the definition of done — see the engineering rules.
  4. Check contrast for both surface families before and after.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close