Astro static site for Firefox's web developer audience. Built from the
Firefox_for_Developers Figma file.
| Command | Purpose |
|---|---|
pnpm dev |
Dev server |
pnpm check |
Prettier + build + ESLint + tests (run before commits) |
pnpm build |
Production build, then astro check and tsc |
pnpm format |
Write Prettier formatting |
pnpm test |
Unit tests for lib/, on Node's own runner |
The build runs before both astro check and ESLint, because it generates the
CSS Module declarations that their type-aware rules read — see
lib/css-module-types.ts. CI is ordered the same way.
pnpm is the package manager and the Node version is pinned in
package.json/.npmrc — use corepack enable rather than a global pnpm.
CI runs pnpm run format:check, pnpm run build, pnpm run lint and
pnpm run test on every PR, in that order. It runs them as separate steps
rather than calling pnpm check, because that script is ;-separated and so
exits with only test's status — a broken build would leave CI green.
Use US spelling everywhere — comments, doc comments, commit messages,
plans, markdown, identifiers and any text the site renders. So color,
behavior, center, gray, optimize, defense, not the -our-/-re/
-ise forms.
It is a consistency rule rather than a style one: CSS and the DOM are
US-spelled (color-scheme, currentColor) and the token names built from
them are too, so one spelling means a search for color finds the discussion
as well as the declaration.
MDN release notes imported into src/content/release-notes/ are excluded:
they are copied verbatim and the importer is re-runnable, so edits there
would be overwritten.
Use display: grid by default, including for one-dimensional layouts.
Grid placement is explicit and doesn't rely on source order, and
grid-template-areas expresses responsive rearrangement far more legibly
than reordering flex items.
Only use flexbox when items need to wrap, which grid cannot do — a
variable-length list of links is the usual case. When you do reach for flex,
leave a comment saying why, as in .nav-list and .legal-list.
Practical equivalents:
- Row of items →
grid-auto-flow: column(notflex-direction: row) - Stack →
display: gridwithgap(notflex-direction: column) - Push an item to one end →
justify-self: end(notmargin-left: auto) - Fill remaining space → a
1frtrack (notflex-grow: 1) - Keep a stack top-aligned →
align-content: start, or tracks collapse to fill the container
No pixel values in CSS. Convert Figma's px numbers at 1rem = 16px, so a 24px
gap becomes 1.5rem and 14px type becomes 0.875rem. This includes values
that look like they should be physical: hairline rules are 0.0625rem, and
visually-hidden clip sizes are 0.0625rem. The layout then scales with the
user's font-size preference instead of ignoring it.
Use em where the value should track its own element's font size — chiefly
letter-spacing, so tracking stays proportional as type sizes change.
Media queries use rem too (@media (width >= 60rem)), which makes
breakpoints respond to root font size.
The exception is the width/height attributes on Astro's <Image> and
imported SVG components: those are intrinsic asset dimensions, not CSS, and
stay unitless numbers. Control the rendered size in CSS.
Unconditional rules describe the narrowest layout, and every media query is
a min-width one (width >= 48rem) adding to it as room appears. Don't write
a width < query.
The narrow layout is usually the simpler of the two — one column, no sticky positioning, full-bleed — so making it the base means the exceptional case is the one behind a query, and a property whose value is the same at every width is written once. It also keeps the fallback honest: anything that can't evaluate the query, printing included, gets the layout that survives the least space.
A consequence worth expecting: a default that is also the CSS initial value disappears rather than being restated.
Breakpoints are chosen per component, at the width where that component's own layout stops working, rather than from a shared set of device sizes.
All color, spacing and type values live in src/styles/tokens.css as custom
properties. Token names mirror the Figma variable names exactly so the two
stay reconcilable — don't rename them, and don't hardcode a value that has a
token. The numbers in the spacing and type token names are Figma's pixel
values, while the values themselves are rem.
Some tokens are off Figma's spacing scale, --space-30 among them. A value
earns a name when it recurs across components, not merely because it is
off-scale — 30 is used in about a dozen places in the editorial designs, so
one named value beats a dozen literals each repeating the same comment.
Don't inline those back into literals. A genuine one-off still gets a literal
and a comment, as with the 38 the blockquote's quote mark hangs by.
Mode-dependent colors use light-dark(), with color-scheme: light dark
set on :root. Where a whole subtree should look the same in both
schemes, prefer pinning its color-scheme over a second set of values: the
footer is color-scheme: dark plus --color-background-main and
--color-heading, read the normal way round, so the scheme is stated once
instead of being encoded into every color.
That only works when every color on the element moves together, which is why
--color-purple-fixed and --color-white-fixed remain plain values. A button
pairs a fixed purple fill with a mode-dependent label (--color-heading on
the outlined style) and a mode-dependent disabled state on the same element —
one element gets one color-scheme, so pinning it would freeze the label to
near-black on a dark page.
Never invent a dark-mode value; the figma-shared-design skill says where to
read them from. Where the design genuinely has no
value — Figma's pages show buttons only at rest — derive one from a token
with color-mix() rather than picking a hex, and check the result: the
buttons' hover and pressed fills darken in light mode and lighten in dark,
because a darkened fill on the near-black dark page drops to 1.5:1 against
it, while lightening past ~16% white puts the white label under 4.5:1.
Figma nodes marked trim-both / cap_alphabetic must get
text-box: trim-both cap alphabetic in CSS. The design's spacing values
assume the font's ascender/descender leading has been removed, so omitting
this makes everything sit slightly low with too much vertical space.
Apply it only to nodes that actually carry the flag. In the footer the link
items are trimmed but the column headings are not. text-box support can be
assumed; no @supports guard is needed.
Use CSS nesting to keep a component's rules together, but not as a way to mirror the DOM. Nest when it removes a repeated selector or moves a rule next to the thing it modifies:
- States and attribute variants —
&:hover,&:not(:focus-visible),&[aria-current='page']go inside the base rule. They are never read independently of it. - Media query overrides — put the
@mediablock inside the rule it overrides, so a component's responsive behavior reads top to bottom in one place instead of being scattered across breakpoint blocks at the bottom of the file. Keep the breakpoint comment with the block. - Descendants tied to one parent —
.footer { & a { … } }where the selector only ever means "a link in the footer". Use the explicit&rather than a barea, so the selector reads as a nested one at a glance.
Don't nest when it hurts:
- Don't nest past two levels. Deep chains make the effective selector hard to reconstruct and raise specificity invisibly.
- Don't nest siblings. Rules that merely appear near each other in the markup, like the reset's element selectors, stay flat.
- Don't nest a component's top-level layout rules into each other
just because the elements are nested in the DOM.
.column-headingstays a top-level rule; burying it inside.columngains nothing and makes it harder to find.
Grouping breakpoints per rule does mean the same @media condition is
written more than once. That is the intended trade: the duplication is in
the condition, which is cheap to read, rather than in the distance between
a property and its override.
- Modern features are welcome and preferred: logical properties, range media
queries (
@media (width >= 60rem)),color-mix(),text-wrap: balance,100dvh,interpolate-size. - The reset lives in a
@layer reset. Unlayered rules beat layered ones whatever their specificity, so component styles win without juggling — the layer does that work on its own and reset rules need no:where(). Outside the layer it is still load-bearing::where(h1, h2, h3, h4)and:where(p)inglobal.cssare unlayered like component styles, so:where()is what keeps them overridable. - Astro
<style>blocks are scoped by default — use them for component CSS and keepglobal.cssfor the reset and shared primitives like.wrapper. Preact components have no such thing, so they use a sibling*.module.cssinstead (see "Shared components" below).
- Import with the
~/*alias (→src/*), not deep relative paths. - Components take a
Propsinterface;strictestTS means optional props need explicit| undefined. MainLayoutis the bare document shell (head, fonts, global CSS).SiteLayoutextends it with the header, footer and skip link — pages should useSiteLayoutunless they deliberately want no chrome.- Fonts are configured in
astro.config.tsand rendered by<Font>inMainLayout. Both families are variable, weights 200–700. Mozilla Headline comes from Fontsource, whose latin subset is markedly smaller than the foundry file. Mozilla Text is self-hosted fromsrc/assets/fonts/instead, because Fontsource has no italic for it and one family cannot span two providers. Only the upright faces are preloaded, so the italic file is fetched only when italic text appears. build.format: 'preserve'— keep file paths as authored.build.cssTargetpins the browsers Lightning CSS minifies for, and is load-bearing in both directions — unset it emits draft syntax nothing implements, lower it andlight-dark()stops answering to a pinnedcolor-scheme. Its comment inastro.config.tshas the whole story. Minified CSS is only in the built site, so a bug here is invisible inpnpm dev.
A component used from both Astro and Preact is written as Preact, in .tsx —
Astro can render Preact, but not the reverse, so Button/index.tsx is
importable from either. Without a client:* directive it renders to HTML at
build time and ships no JavaScript: the Preact chunks Vite emits stay
unreferenced and the browser never fetches them. Keep it that way — these
components hold no state and take no event handlers.
Type the function as FunctionComponent<Props> rather than annotating the
return type; it wraps props in RenderableProps, which supplies children,
key and ref, so Props should not declare children itself. Name the
class prop class, not className — Preact accepts both, and class reads
naturally from Astro call sites. Neither of these produces a type error if you
get it wrong, unlike most of what follows.
Styles go in a sibling *.module.css, since .tsx has no scoped <style>.
Import it as a namespace rather than with bare named imports, so the styles.
prefix marks each use site as a class name rather than a local:
import * as styles from '~/components/Button/index.module.css';Astro passes its data-astro-cid-* scope hash through to the component's root
element, so a parent's scoped rule (.cta { grid-area: cta }) still reaches
it — keep layout and placement in the parent, the component's own appearance
in its module.
The .d.ts beside each *.module.css is generated by
lib/css-module-types.ts. Never edit or commit one; that file's doc comment
covers why they are gitignored, why there is deliberately no default export,
why class names must be camelCase, and why the build has to run before
astro check and ESLint.
prose.css styles long-form content, scoped with @scope (.prose) to (.prose-end) so a component can opt out of the prose rules and back in.
It keeps what has no component — headings, lists, tables, code, definition
lists — plus the margins that run between blocks, including a blockquote's
own outer margin. Appearance belongs to the component; spacing between
siblings belongs to the stylesheet that can see both.
A construct with a component behind it keeps its styles in that component,
not in prose.css. Markdown syntax is mapped onto the component in
lib/markdown/prose-components.ts, so the component is not an alternative to
authored markup — it is what authored markup renders as. Blockquote works
this way: an author writes > and gets it.
That mapping is a Vite plugin, which appends a components export to every
MDX file — @astrojs/mdx merges one from the module itself. So a page
renders a bare <Content />, and adding a mapping is one line in
MAPPED_COMPONENTS, for an .astro component as readily as a .tsx one.
Read that file's doc comment before changing it.
The reason it is not the obvious <Content components={…} /> at each call
site, or a wrapper component sharing one copy of the mapping, is that the
wrapper breaks every component an MDX file imports. Before a page streams,
Astro collects the components that contribute to <head>, which is how an
entry's imported components get their styles onto the page. It finds them by
construction — instantiating a component calls its slot functions
immediately, so a <Content /> nested in layouts is reached — but a
component passed as a prop is an inert value until something renders it,
and a wrapper renders it from inside its own body, after the head is out. So
<Wrapper><Content /></Wrapper> is fine and <Wrapper {Content} /> is not,
and a wrapper whose whole job is to supply components can only be the
latter. Leaving nothing to pass is what makes the mistake unavailable.
That failure is silent and leaves the markup with none of its rules, in
pnpm dev as much as in a built page. What makes it easy to misread is that
a page importing the same MDX as a plain module, as /test/prose/ does, is
styled correctly either way — there the component is in the page's own module
graph and no propagation is involved — so checking the specimen proves
nothing about a release note.
A component an author calls by name is imported by the MDX file that uses
it, the ordinary way, and Note works this way. Only markdown syntax's own
components go in MAPPED_COMPONENTS: what is listed there is appended to
every MDX file, so its CSS reaches every page that renders prose whether or
not the page uses it. A blockquote earns that because any prose page can
produce one; a <Note> does not.
Two satteri plugins normalize the markup prose.css styles. Each carries its
full rationale in its own doc comment — read the file before changing it:
lib/markdown/loose-blocks.ts— forcesspreadon every list, item and description, so an author's blank lines don't decide whether list text is wrapped in a<p>.prose.cssthen stylesli > panddd > palone.lib/markdown/definition-groups.ts— a hast plugin wrapping each term/description group in adiv, which the HTML spec allows, soprose.csscan styledl > divinstead of inferring the grouping from sibling position.
What this leaves for an author to get right:
- A list or definition list written as JSX bypasses the markdown parser,
so
looseBlockscannot reach it — write the<p>in a JSXliby hand.definitionGroupsdoes reach both forms, since it runs at hast. - In a
ddwritten as JSX, leave the text bare. MDX wraps multi-line text in a paragraph of its own, which is the one the plugin would have produced; an explicit<p>around it nests invalidly. Keeping the text on one line to dodge this is not stable, because Prettier rewraps a long line. - Prettier has no definition-list support — it reads nested markdown pairs as a paragraph and reflows them to the margin — so a nested definition list has to be written as JSX.
When adding a component to the mapping:
- It must render the element it replaces, so authored markup and an explicit call produce the same thing.
- Style markdown-generated descendants through
:global(). Astro's scope hash lands on the component's root element and never on children that came from markdown, so a bare& pcompiles top[data-astro-cid-…]and matches nothing.& :global(p)compiles toblockquote[data-astro-cid-…] p, which is scoped where it needs to be and open where it cannot be. - Rules in
prose.cssthat reached inside the element have to move with it — a:is(li, blockquote) > :not(:first-child)cannot see into the subtree any more.
The design lives in the Firefox_for_Developers Figma file.
Before touching it — implementing a frame, measuring spacing, checking an
existing component against the design, or adding a color token that needs a
dark value — load the figma-shared-design skill
(.claude/skills/figma-shared-design/). It holds the file key, the node IDs
for the light and dark Home pages, and the traps in get_design_context,
get_variable_defs and oversized MCP results that have each cost a wrong
implementation once already. figma-design-to-code is still the skill to
load before calling get_design_context itself.
Two rules bind even when you never open Figma: never invent a dark-mode value, and never snap a measured value to the nearest token to make it look tokenised. A positional value gets a literal and a comment — see "Design tokens" above. The skill owns the spacing scale itself, so it is stated in one place rather than two.
Every fenced block is run through Prettier at each width in TIERS, every
distinct result is emitted, and container queries show the widest one that
fits — so a sample reads well at both full desktop measure and phone width.
Three files, each documenting its own reasoning in full:
lib/markdown/code-widths/format.ts—TIERS, the language→parser map andformatVariants. Pure, and the only part with unit tests (format.test.ts).lib/markdown/code-widths/plugin.ts— the satteri mdast plugin that wraps each block and emits the variants. It runs before Astro's Shiki step, which is what lets one block become several without instantiating a highlighter.lib/markdown/code-widths/indent-wrap.ts— a Shiki transformer splitting each line into an indent cell and a content cell, so a line that outruns its tier wraps under its own content instead of back at the gutter. Configured throughmarkdown.shikiConfig, because Shiki runs after the mdast plugins.
Before changing any of it, read those doc comments: they cover why the floor is 40, why the variants cost almost nothing compressed, and why the indent wrapping is the safety net under the whole approach.
Three things bind from outside those files:
TIERSand the@containerblocks inprose.cssare kept in step by hand. Four blocks is not worth a generator. Getting a tier wrong is not fatal either way — too narrow means more line breaks than necessary, too wide and the indent wrapping catches the overflow.- The container query thresholds are in
ch, and that is exact rather than an analogy. A size feature queries the container's content box, which.code-block's padding makes exactly the text area, and a font-relative unit resolves against the container's own computed font. So48chmeans literally "48 code characters fit", the same questionprintWidthanswers. - Declare the font on the
preandcode, not just on the container. The UA stylesheet declaresfont-family: monospaceon both, and an inherited value loses to any declaration including a UA one, so a family on.code-blockalone never reaches the text — the block then renders at 0.602em per character while the query measures Inconsolata's 0.5em, making every threshold 20% optimistic and the chosen variant overflow. Inlinecodehits the same UA declaration, which is whyprose.cssstates the family there too.
The colors are lib/markdown/code-theme.ts, a Shiki theme whose every color
is a CSS custom property rather than a hex. Shiki copies a color it does not
recognize straight into the span's inline style, so one theme covers both
schemes: each token resolves through light-dark() in tokens.css at paint
time, the same way the rest of the site does it. Read that file's doc comment
before changing a scope — it records which six roles the design actually
specifies, the rule used to extend them to every other language, and which of
the design sample's own inconsistencies were deliberately kept.
Every block is wrapped in .code-block, variants or not, so one set of
rules owns the block's background, padding and query container however the
block was treated. A block that opted out, one whose language has no parser
and one that dedupes to a single variant all produce .code-block > pre; only
a block that genuinely reflows gets .code-variant children.
Authoring escape hatches, read off the fence's meta string and stripped from it before Shiki sees them:
```js no-format— ship exactly what the author wrote.```js width=80— one variant at a fixed width, for a sample whose point is a particular line layout.// prettier-ignoreworks inside a block, and the directive line itself is removed before the block reaches the reader.
Don't promise responsive HTML samples: Prettier's HTML printer barely
responds to printWidth — it breaks one attribute per line and stops — so
those blocks dedupe to a single variant. That costs nothing, but it isn't a
feature either.
A block that asked to be formatted and couldn't — an illustrative fragment
like if (foo) { with no closing brace — ships as authored and logs a
[code-widths] line naming the file. A burst of those warnings means the
language map or the fragment story needs work.
/test/code/ is the specimen page for all of this, and its column is
resizable because the tiers are chosen by a container query. The code fences
in src/pages/_test/code.mdx are exempt from Prettier in .prettierrc: the
input formatting is the thing under test there, so it cannot also be
Prettier's own output.
Specimen pages for checking styles against the design live under
src/pages/test/, one directory per page, and are absent from the production
build rather than present and hidden.
| Command | /test/* |
|---|---|
pnpm dev |
included |
pnpm build |
omitted |
INCLUDE_TEST_PAGES=1 pnpm build |
included |
pnpm check |
included |
pnpm check sets INCLUDE_TEST_PAGES=1 for its build step deliberately.
A test page is sometimes the only consumer of a piece of lib/, and
astro check only type-checks what the build pulled in — so checking the
production build alone leaves that code unchecked and lets a type error
reach CI.
Each page gates itself by exporting the shared getStaticPaths from
src/pages/_test/_test-pages.ts:
---
import { testPagePaths } from '~/pages/_test/_test-pages';
export const getStaticPaths = testPagePaths;
---That is why every test page is a dynamic route — test/buttons/[...slug].astro
rather than test/buttons.astro. Only a dynamic route runs getStaticPaths,
and returning no paths is what drops the page from the build; a static page
has no equivalent escape hatch. The rest parameter is load-bearing, not
decorative. build.format: 'preserve' renders the single slug: undefined
path at the bare /test/buttons/.
INCLUDE_TEST_PAGES is declared in src/env.d.ts, which strictest needs in
order to read it off import.meta.env.
Write the page's markup in the .astro file. That is the normal case, as
in test/buttons/: a component matrix is not authored content, so there is no
markdown an author would write that produces it.
Use MDX only for what is actually authored as markdown — currently just
prose. test/prose/ imports _test/prose.mdx so the content goes through the
real pipeline, satteri included, and tests what an author's markdown
produces rather than hand-written HTML that could drift from it. MDX content
lives in _test/, which Astro leaves out of routing, so the file does not
become a route of its own.
Two MDX traps, both of which have already produced wrong output here:
- Bare text on its own line inside a JSX block is still markdown, so MDX
wraps it in a
<p>. In a blockquote footer that paragraph inherits theblockquote prule and overrides the footer's ownbody-xs. Use theBlockquotecomponent rather than hand-writing the footer. - Don't write an explicit
<p>around multi-line slot content — MDX adds its own, giving invalid nested<p><p>. Leave the text bare and let MDX make the paragraph.
Prettier also rewrites a block {/* … */} comment between markdown blocks
into {/_ … _/}, which breaks the build. Keep MDX comments on one line inside
JSX, or write the explanation as prose on the page.
Link destinations in the header and footer are inferred from labels, since the design only specifies text. They need checking against what the team actually intends.
Only desktop frames (1440px) exist in Figma, so responsive behavior below that is an interpretation rather than a spec. The code block's inline padding holding at 1rem until 30rem is one such interpretation: the design's 2rem either side is a fifth of the text area on a phone.
The syntax theme's coverage is bounded by the CSS grammar's property list,
which is an allowlist older than most of what this site writes about. An
unrecognized property still gets its color, but the value beside it stays
plain — lib/markdown/code-theme.ts explains why the theme cannot fix that
end of it. Widening it means a newer grammar upstream, not a change here.