One import

In your site's global stylesheet (or a layout-level <style>):

@import '@dunn.dev/reference/tokens/index.css';

Or import via Astro/Vite from a relative path during early development:

import '../../design/tokens/index.css';

What you get

  • Color tokens: three palette scales (moss, earth, group) plus semantic tokens (--bg, --text, --accent, …). Light + dark via data-theme or prefers-color-scheme.
  • Type tokens: --font-body, --font-mono, --font-ui, --font-display. IBM Plex Sans + Mono.
  • Type scale: --text-xs through --text-4xl. Fluid above the body size, fixed below.
  • Line heights + radii: --lh-base, --lh-tight, --lh-hero; --radius-sm, --radius, --radius-md.
  • Spacing scale (4px base): --space-0 through --space-8. Use for component-internal gaps, paddings, margins. The page frame and measure come from site.css (--frame, --measure).
  • Motion tokens: --duration-instant, --duration-fast, --duration-base, --duration-slow; --ease-out, --ease-in-out. Honors prefers-reduced-motion by zeroing all durations.

Per-site overrides

The estate accepts personality at the token layer, not the architecture layer. Override a token in your site's own stylesheet, after the foundation import:

@import '@dunn.dev/reference/tokens/index.css';

/* ashley.dunn.dev — steel accent */
:root {
  --accent:       #4a6072;
  --accent-hover: #364858;
}

Common personality dimensions: --accent, --font-display (sites that want a serif headline like Fraunces), and any of the callout colors. The page frame, padding and measure are set once in site.css. Don't override them; lay a row out with .split instead.

Page chrome

A project site must take its page shell and rows from @dunn.dev/reference/chrome/; a bespoke site records a reasoned deviation in .deviations.yaml. The components emit the site.css classes, so the site's global stylesheet still imports the tokens and site.css. Astro is a peer dependency.

import ProjectLayout from '@dunn.dev/reference/chrome/ProjectLayout.astro';
import mark from '../mark.svg?raw';

<ProjectLayout siteName="example.dunn.dev" tagline="what it is"
  title="Guides" mark={mark} nav={nav} repoUrl="https://gitlab.com/dunn.dev/example"
  section={sectionForPath(Astro.url.pathname)} headings={headings}>
  <slot />
</ProjectLayout>
  • ProjectLayout: head, header (mark, name, nav, Source, theme toggle), footer. A section ({label, pages: [{href, label, group?, external?}]}) makes a docs page: nav, content and TOC lanes, prose held to a reading measure, and from three h2s an on-page TOC. Omit title on the home page; tagline? and repoLabel? set the footer. Slots: default, head, footer.
  • Hero (title, eyebrow?, live?): lede in the slot, buttons in actions, a captured .term or a .captures pair of screenshots in aside; live renders a link card. Without an aside the lede runs in two columns. SectionLabel (label, meta?, as?): the section's h2 unless as says otherwise.
  • Split: prose, and a figure, code, terminal or card in aside.
  • DiagramTrack (caption?): two to four data-step="N" blocks of about 60 words, an inline SVG in figure. A mark with data-step="N" lights from step N, data-until="N" through step N, unmarked always. Without scroll timelines, under reduced motion and at 900px or narrower the figure is the finished drawing. A diagram with no sequence is a Split.

Short landing prose goes in .flow columns; docs pages never flow. A .page-header sets title left, lede right when wide. A home's overview is .cat-explainer.figure-pair.figure-full, narrow below 1000px. A .tile-grid picks how many tiles a row holds from its tile count and lane width, so no row strands a tile.

Conventions

Tokens that move together

Some tokens are derivatives of others. If you override one in the cluster without re-deriving the rest, the site drifts visually: links read wrong, hover states stop tracking accent shifts, contrast breaks. Override the whole cluster, or use color-mix to derive from the root.

Accent cluster

--accent --accent-hover --accent-contrast --accent-subtle --accent-link

Override --accent and you must also override (or re-derive) --accent-hover and --accent-contrast. The -subtle and -link variants can be derived via color-mix(in srgb, var(--accent) 8%, transparent) and 40% respectively.

Text cluster

--text --text-2 --text-muted

A three-step tonal ramp. Move them together if you change palette (e.g., warmer or cooler neutrals). Don't pin one without the others.

Surface cluster

--bg --surface --nav-bg --border

Page chrome. Surface should sit one step warmer/darker than bg in light, lighter in dark. Border should read as a hairline, not a wall.

Callout cluster

--callout-warning --callout-tip --callout-todo

Three tones. Override as a set so warning/tip/todo stay distinguishable and the trio reads as a family.

Light + dark: the foundation ships dark variants for every semantic token. If you override a cluster in light mode, override the same cluster inside :root[data-theme="dark"] too. Otherwise the dark side keeps the foundation defaults and your accent flips back to moss when a visitor toggles theme.

Stack notes

Traps that come from the stack, not the design

Pinning light

The published tokens gate their dark block on :root:not([data-theme="light"]). That gate is the consumer's opt-out: <html data-theme="light"> keeps the light tokens active whatever the visitor's system preference says. The web-starter template pins light by default.

The reason is graphic. Most projects in the estate ship line-art diagrams and SVG metaphors authored against a bright surface: paper-cream ground, moss strokes. When the page background flips dark the embedded SVGs do not, and a stroke that reads at 1.5px against paper disappears against the new canvas. Sequence diagrams, flow diagrams and the album metaphor mark all share that fragility.

The tokens set color-scheme with the theme, so browser-native chrome (scrollbars, form controls, date pickers) follows the pin, the toggle and the system preference alike.

When not to pin: sites that are text and code first, with no authored art depending on surface color. Those respect the system and let the tokens flip. This site does, and its dark side reads because every page's contrast comes from the tokens rather than from an illustration.

Astro content collections lowercase the id

The glob loader lowercases entry IDs. Where the on-disk path carries uppercase, an ISO timestamp's T and Z or a vendor-cased slug, an entry.id-derived external URL 404s against a case-sensitive remote.

// BROKEN: id is lowercased.
const dir = entry.id.replace(//manifest$/, '').split('/').pop();

// FIXED: filePath preserves on-disk casing.
const fp = (entry as { filePath?: string }).filePath ?? '';
const rel = fp.replace(/^.*?(evidence/)/, '$1').replace(//manifest.json$/, '');

The same trap bites an import.meta.glob lookup keyed on the id. Reach for filePath whenever the path has to match the real filesystem.

astro dev versus astro preview

astro preview serves the static dist/ build with no HMR. If edits are not appearing in the browser, that is almost certainly what is running:

ps -p <pid> -o command | grep -oE "astro (dev|preview)"

Iterate on astro dev on a fresh port. The estate commonly has preview servers lingering on 4322 and 4323; pick 4399 or above.

Both daemonize in Astro 7: the launching command exits 0 while the server keeps running, so a finished background task is not a stopped server. npx astro dev stop in the project ends it.

A new collection file needs a restart

The glob loader sometimes misses a newly created .md, especially in a collection that was empty at boot. The symptom is a new page 404ing in dev while the file plainly exists. Restart astro dev after adding one.

Remark plugins need @astrojs/markdown-remark

Astro 7's default Markdown processor no longer brings in @astrojs/markdown-remark, yet a config that lists remark or rehype plugins still runs them on that pipeline. Declare it as a direct devDependency. A lockfile that happens to carry it masks the gap until a lockfile refresh prunes it; then the build fails or the plugins quietly stop, and a relative-link rewriter's links 404.

375px is the floor

Every site survives 375px. Four reflows below 640px cover nearly every surface in the estate:

@media (max-width: 640px) {
  /* Header nav wraps under brand. */
  .site-header-inner { padding: var(--space-3) var(--space-4); gap: var(--space-2); }
  .site-nav { margin-left: 0; width: 100%; gap: var(--space-2) var(--space-3); }
  main.page { padding: var(--space-5) var(--space-4); }

  /* Data tables scroll horizontally. */
  table.data {
    display: block;
    overflow-x: auto;
    white-space: nowrap;
    -webkit-overflow-scrolling: touch;
  }

  /* Footer wraps cleanly. */
  .site-footer-inner { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }

  /* Key/value lists stack. */
  dl.kv { grid-template-columns: 1fr; gap: 0 var(--space-2); }
  dl.kv dt { margin-top: var(--space-2); }
  dl.kv dt:first-of-type { margin-top: 0; }
}

Check it in a real viewport, not a resized window: nav reads as two rows, brand then links; a data table scrolls inside itself instead of pushing the page wider than the screen; the footer wraps.