Foundation
Every site in the estate imports the same foundation. Sites override
individual tokens (typically --accent) for personality,
not the whole stack.
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 viadata-themeorprefers-color-scheme. -
Type tokens:
--font-body,--font-mono,--font-ui,--font-display. IBM Plex Sans + Mono. -
Type scale:
--text-xsthrough--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-0through--space-8. Use for component-internal gaps, paddings, margins. The page frame and measure come fromsite.css(--frame,--measure). -
Motion tokens:
--duration-instant,--duration-fast,--duration-base,--duration-slow;--ease-out,--ease-in-out. Honorsprefers-reduced-motionby 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. Omittitleon the home page;tagline?andrepoLabel?set the footer. Slots: default,head,footer. - Hero (
title,eyebrow?,live?): lede in the slot, buttons inactions, a captured.termor a.capturespair of screenshots inaside;liverenders a link card. Without an aside the lede runs in two columns. SectionLabel (label,meta?,as?): the section's h2 unlessassays otherwise. - Split: prose, and a figure, code, terminal or card in
aside. - DiagramTrack (
caption?): two to fourdata-step="N"blocks of about 60 words, an inline SVG infigure. A mark withdata-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 aSplit.
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.