Skip to content

Design Direction

The astro-basics visual direction is seven colour tokens, three type roles, and one structural rule that ties colour to interactivity. These tokens are the public styling contract for projects that import this package, so the names and their meanings are stable and every one of them is overridable from a consumer stylesheet.

The direction is one idea specific to Astro rather than to websites in general.

Astro ships static HTML and withholds JavaScript. The palette says the same thing the framework does: static content renders in ink on paper, and the accent, --island, appears only where something is interactive - links, buttons, focus rings, the interactive-specimen badge. Nothing decorative carries it.

Ink for what is static

Headings, body copy, captions and card surfaces use --ink, --ink-soft, --paper and --paper-sunk. If a visitor cannot operate it, it does not get the accent.

Island for what responds

Links, buttons, focus rings and the specimen badge use --island and --island-bg. The accent is a signal that something will react.

Defined in src/styles/_design-tokens.scss and consumed in src/styles/_base.scss.

Token Light Dark Role
--ink #101418 #e8eaed Primary text; the default color on body
--ink-soft #5a6472 #99a2ad Secondary text: decks, eyebrows, labels, captions, inline code
--paper #fcfcfd #0d1014 Page background
--paper-sunk #f1f3f5 #151a20 Recessed surfaces: the header band, card backgrounds
--island #0b6070 #6bb9c9 The accent. Interactive only
--island-bg #e6f2f6 #102a33 Accent wash behind interactive affordances and ::selection
--rule #dde1e6 #262c33 Hairlines and dividers

The accent is a deep petrol — a low-chroma blue-green. It is deliberately neither Tailwind sky-500 nor the violet/indigo family that generated palettes converge on, so the page does not read as a framework default. Low chroma is the point: the accent sits beside ink as a second voice rather than shouting over it, and it darkens cleanly under the color-mix hover states instead of going muddy. Every pair clears WCAG 2.1 Level AA. Measured against the running page rather than estimated: in light, ink on paper is 18.04:1, ink-soft on paper 5.85:1, island on paper 7.01:1, and the lowest pair of all — ink-soft on paper-sunk — 5.39:1; in dark, island on paper is 8.54:1 and the lowest pair — again ink-soft on paper-sunk — is 6.77:1. e2e/home-accessibility.spec.ts asserts the 4.5:1 AA floor against the tokens the page actually resolves, so no repoint can drop a pair below AA without failing the build. The exact figures above are not themselves asserted — they record the current budget, and re-measuring is part of changing a token.

Both themes are SCSS mixins applied through three scopes:

:root {
@include direction-light;
}
@media (prefers-color-scheme: dark) {
:root {
@include direction-dark;
}
@include direction-dark-surfaces;
}
:root[data-theme='dark'] {
@include direction-dark;
@include direction-dark-surfaces;
}
:root[data-theme='light'] {
@include direction-light;
}

:root[data-theme] carries specificity (0,2,0) against the media query’s (0,1,0), so an explicit toggle beats the OS preference regardless of source order. Starlight already stamps that attribute on these docs routes.

Two component aliases sat in the token file with zero consumers, which is why every painted surface inherited @fpkit/acss defaults. Both now point at direction tokens and both have real painted consumers.

Aliased to var(--paper-sunk) and painted by body [data-card], plus the same selector inside the dark scopes.

[data-card] is the attribute @fpkit/acss puts on its Card root, so the alias reaches:

  • Card.astro everywhere it renders, including the six homepage feature cards and the hero specimen
  • the promoted-tier specimens in FeatureCards.astro
  • Featured.astro
  • DashboardCard and anything else composed on the @fpkit/acss Card
body [data-card] {
background-color: var(--card-background);
}

The selector is scoped through body on purpose. The vendor rule is (0,1,0); body [data-card] is (0,1,1) and wins on specificity rather than on whichever stylesheet the bundler emits last. It also leaves the card’s own header strip alone.

One typeface used to render across every heading, paragraph, link and button, so elements differed only by size. Three roles is the smallest set that reads as deliberate.

Role Token Applies to Treatment
Display --font-family-display h1 through h6 Inter 600, negative tracking
Body --font-family-sans body, p, li, everything inheriting from body System sans stack
Mono --font-family-mono code, kbd, samp, [data-ui="eyebrow"], [data-ui="label"] Uppercase, 0.12em tracking, --ink-soft
  • Display covers every heading level. h1 through h6 all render at weight 600, which is the single weight the self-hosted woff2 ships - so the full range costs no additional font request.
  • Casing is not part of the role. text-transform: capitalize stays on h1-h3, which are titles. h4-h6 are used for UI labels - the contact form renders its error summary as an h6 - and capitalizing a sentence would produce “Please Correct The Following Errors”.
  • Tracking is negative on display. -0.015em on h1-h6, tightened to -0.02em on h1.
  • The display stack falls back through the system sans, so a failed font request degrades to the body face rather than to a serif.
  • Body is declared on body, not html. @fpkit/acss sets font-family on html, which is why one face used to render everywhere. Declaring the body role on body beats that by inheritance and leaves the headings free.
  • Mono is opt-in for anything that is not code. Code semantics get it by element; everything else opts in through a data attribute, so an eyebrow is a deliberate choice at the call site rather than a selector guessing at class names.

The display face is self-hosted: a latin-subset, single-weight inter-600.woff2 in public/fonts/, declared with font-display: swap and preloaded from src/layouts/Base.astro. No CDN and exactly one font request.

<p data-ui="eyebrow">30+ components / zero client JS by default</p>
<figcaption data-ui="label">Rendered</figcaption>

Both attributes resolve to the mono family, --ink-soft, 0.75rem, 0.12em tracking and uppercase.

Projects importing this package get the components, and the components read these custom properties at runtime. Nothing is compiled in, so a consumer rebrands the kit by redefining properties - no fork, no SCSS build, no component edits.

Two rules make an override stick:

  1. Load your sheet after the kit’s stylesheet. Every override below has the same specificity as the kit’s own declaration, so source order decides the winner.
  2. Override all three scopes. The kit sets the tokens in :root, in @media (prefers-color-scheme: dark) :root, and in :root[data-theme='dark'] and :root[data-theme='light']. Redefining only :root leaves the dark path on the kit’s values.
/* your-app/src/styles/brand.css - imported after the kit's index.css */
:root,
:root[data-theme='light'] {
--ink: #12100e;
--ink-soft: #6b6257;
--paper: #fffdf9;
--paper-sunk: #f5f0e6;
--island: #b4531a;
--island-bg: #fdeee2;
--rule: #e4dbcb;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #f4efe7;
--ink-soft: #a89a88;
--paper: #14110d;
--paper-sunk: #1d1913;
--island: #ff9a5c;
--island-bg: #2a1a10;
--rule: #2e2820;
}
}
:root[data-theme='dark'] {
--ink: #f4efe7;
--ink-soft: #a89a88;
--paper: #14110d;
--paper-sunk: #1d1913;
--island: #ff9a5c;
--island-bg: #2a1a10;
--rule: #2e2820;
}

You do not need to re-declare the painted rules. body, body > header, body [data-card] and ::selection all resolve through var(), so redefining the variables repaints every surface that consumes them.

--island is the only token bound to a structural rule. If you re-point it, keep it on interactive surfaces only. Applying your accent to a heading, a decorative border or a static card background reintroduces exactly the ambiguity the direction removes - and it fails e2e/homepage-design-direction.spec.ts if you run this project’s suite.

Measured against the running dev server in both themes:

Surface Light Dark
body background rgb(252,252,253) rgb(13,16,20)
body colour rgb(16,20,24) rgb(232,234,237)
body > header (--header-background) rgb(241,243,245) rgb(21,26,32)
[data-card] (--card-background) rgb(241,243,245) rgb(21,26,32)
Link colour (--island) rgb(11,96,112) rgb(107,185,201)
code colour (--ink-soft) rgb(90,100,114) rgb(153,162,173)

Automated coverage:

  • e2e/homepage-design-direction.spec.ts - three distinct font families resolve, the display-to-deck ratio clears 3.0, both aliases have at least one consumer, body background differs between light and dark, no non-interactive element carries the accent, and horizontal overflow is 0 at 320, 390, 768 and 1280
  • tests/integration/design-tokens.test.ts - --card-background and --header-background each keep at least one var() consumer in the compiled CSS, and the dark block sets at least one painted property rather than variables alone
File Purpose
src/styles/_design-tokens.scss Token definitions, both themes, the font face
src/styles/_base.scss The painted consumers and the type roles
src/components/astro/HomeHero.astro Homepage hero and the live component specimen
src/styles/DESIGN-TOKENS-README.md The wider token layer this direction extends
project-docs/03-features/design-direction.md The same contract in the project docs