Customization
Theme documentation
Everything site-specific lives in src/config.ts and src/styles/theme.css. You should not have
to open a component for any of the changes below.
Site identity
src/config.ts, the site object:
name— the wordmark in the header, the footer, and every page title. It is set type, not an image: change the word here and the typeface sets it.description— the meta description, the feed, and the fallback share card.url— your production URL. Set it before you deploy: canonical links, the feed, the sitemap androbots.txtall print absolute URLs from it.locale— a BCP 47 tag. It is thelangattribute, and it decides how dates and prices are formatted (Intl). The words the theme prints are inui; see Words and language.author— the fallback byline on a post, and the name in the JSON-LD.defaultOgImage— the share card for any page that has no generated one. Nothing ships at/og-default.png: withseo.ogImageson, every post and documentation page gets a drawn card and the rest of the site falls back to this path, so put a 1200×630 file inpublic/(or point this at one) if you turn the generator off.
Words and language
ui at the end of src/config.ts holds every word the theme prints around your content: the
skip link, the theme switch, the docs breadcrumb, sidebar and pager, “On this page”, the blog and
changelog headings, the contact form, the 404 page, the price units on /pricing, and in Pro the
search, the copy button, the callout, parameter and plan tables, and the series and author blocks.
To publish in another language, set site.locale and translate ui. Counts are functions, so
plurals stay right: posts: (count) => `${count} Artikel` . Strings marked HTML carry a link or
a <code> and are printed as markup: keep the tags when you translate them.
The marketing pages are written in their own files, not in ui: src/pages/index.astro,
features.astro, pricing.astro (the headings, the FAQ), integrations.astro,
security.astro and about.astro.
Navigation, footer and social links
src/config.ts, the nav object:
nav.header— the links in the sticky bar. Four is what fits a phone beside the wordmark and the icons; a fifth wraps.nav.cta— the one button to the right of the links. One, not two.nav.footer— groups, each atitleand a list oflinks.nav.social— icon links.iconis a Lucide name (lucide:github, see lucide.dev/icons). Your own SVG goes insrc/icons/and is referenced by its filename without the extension.
Colours and other tokens
src/styles/theme.css is your override file. Every token and its default is in
src/styles/tokens.css — read that file, do not edit it. Declarations in theme.css are
unlayered, so they win without specificity tricks:
:root {
--primary: light-dark(#8c1f2f, #e08792);
--content-width: 42rem;
}Colours use light-dark(<light>, <dark>). Overriding with a plain colour changes both modes at
once. The tokens worth knowing:
--background,--surface,--foreground,--muted,--faint,--border— paper, ink, and the two greys between them.--surfaceis the tint on code blocks, callouts and panels;--faintis every mono label: rail numbers, filenames, breadcrumbs, dates.--primary,--primary-hover,--on-primary,--focus-ring— the one accent. It carries the rail numbers, the current position and the focus ring, and nothing else. There is no second accent by design; a coloured section background is not the instrument this theme plays.--danger— invalid fields, their messages, and a breaking release in the changelog rail.--code-comment,--code-keyword,--code-string,--code-number,--code-function— five scopes and the ink. The syntax colours are tokens rather than an imported Shiki theme, which is what lets the blocks follow light and dark with the rest of the page and be measured for contrast like everything else.--content-width(38rem — the one measure, marketing copy and documentation body alike),--wide-width(the page container),--docs-width(the wider three-column shell),--rail-widthand--rail-gap(the left margin the index lives in).--shell-widthis computed from the three and is not a knob.--radius(3px),--transition-fast.
Change a colour and run pnpm check:contrast. It measures every text token on every background,
light and dark, in the defaults and in all four presets, against WCAG AA and prints the ratios.
Add a token to one of the two lists at the top of scripts/check-contrast.mjs and it is measured
from then on.
Colour presets
Four palettes ship beside the default: slate, clay, ink and plum. Uncomment one import at the top
of src/styles/theme.css:
@import './tokens-clay.css';The import has to stay above your own rules, which is also the order that lets those rules win.
A preset replaces the colour tokens only — type, spacing and the five syntax colours stay put,
because a code block is the same ink on any paper. Each preset is measured by
pnpm check:contrast along with the defaults, so a palette of your own belongs in the same shape:
copy tokens-slate.css, change the values, run the check.
Dark mode
features.darkMode prints the toggle in the header. The colours themselves are light-dark()
pairs in the tokens, and the switch only writes data-theme on <html> plus a localStorage
entry, so there is no dark: variant to write and no second palette to keep in sync. Turn the
flag off and the site follows the operating system.
Webfonts
fonts: in astro.config.mjs. Two faces, both OFL and both self-hosted by the fontsource
provider: Public Sans on --font-body and JetBrains Mono on --font-mono. --font-heading
points at --font-body, because headings here differ by weight, not by face.
To swap a face, change name in that block to another fontsource family and keep the
cssVariable. Load the weights you actually use — 400, 500 and 600 for the body, 400 and 500 for
the mono — and keep the preloads in src/layouts/Base.astro pointing at faces that exist. Do not
link an external font CDN; the point of the provider is that nothing leaves your domain.
For a system stack instead, override --font-body in theme.css and delete the block.
The marginal index
The signature: a numbered label that stays in the left margin while its section scrolls.
features.marginalIndex = false drops the label inline above the section heading instead, and
nothing else about the page changes.
A section is one <Section> (src/components/common/Section.astro) inside a .rail container:
<Section label="How it works" id="how" class="mt-24">
…
</Section>- The number is a CSS counter over the sections in that container, so it is never typed and never
out of date.
numbered={false}prints the label without one. noteis a second line in the rail (the changelog puts the release date there) andflagis the same line in--danger.asis the label’s element. It is a<p>by default; on the changelog, where the version is the outline of the page, it isas="h2", and the entry’s own###then follows in order. Do not set it to a heading where the section title is not part of the outline.
Below 900px the label moves above its section on every page, whatever the flag says.
The product shot
Section 01 of the home page ends with src/components/sections/ProductFrame.astro: a drawing of
the product in rules and mono text. It is drawn rather than photographed on purpose — there is no
asset to license, no second file to re-shoot for dark mode, and it stays sharp at any width. The
rows are a const at the top of the file, so the quickest change is to put your own query and
your own results there.
To use a real screenshot instead, replace the body of that file. Nothing else reads it:
---
import { Image } from 'astro:assets';
import shot from '../../assets/app.png'; // anywhere under src/, not public/
---
<figure class="border-border border">
<Image
src={shot}
alt="Quarry showing three matches for a search across a notes folder"
class="block w-full"
loading="eager"
/>
</figure>loading="eager" is not optional here. <Image> is lazy by default, which is right for a picture
further down a post and wrong for this one: section 01 is within a screen of the fold, so the shot
is a candidate for the page’s largest contentful paint, and leaving it lazy is the difference CI
measures. Everything below the fold keeps the default.
Keep the <figure>. A screenshot carries its own background, and in light mode most application
windows are close enough to --background that the image loses its edges and stops reading as an
object; the hairline is what the drawing had and what gives it back.
Width
The section body is capped at the reading measure (--content-width, 38rem), which is narrow for
an application window. Moving the <ProductFrame /> out of its <Section> in
src/pages/index.astro lets it run to the full shell width:
The shot is then wider than the column above it and sits outside the rail, so its left edge no longer lines up with the heading. That is a trade, not a bug — decide which of the two matters more on your page.
Dark mode
A screenshot does not follow the theme, so a light one sits as a light rectangle on a dark page. If that is not acceptable, ship both and swap them.
Do not reach for <picture media="(prefers-color-scheme: dark)"> here: it follows the
operating system, and this theme’s switch writes data-theme, which overrides the system setting.
A visitor on a dark desktop who picks Light would get the dark screenshot on a light page. Render
both images and let CSS choose, with the same two guards the tokens use:
<figure class="border-border border">
<Image
src={light}
alt="Quarry showing three matches"
class="shot-light block w-full"
loading="eager"
/>
<Image
src={dark}
alt="Quarry showing three matches"
class="shot-dark block w-full"
loading="eager"
/>
</figure>/* src/styles/theme.css — declarations here are unlayered, so they win as they stand. */
.shot-dark {
display: none;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .shot-light {
display: none;
}
:root:not([data-theme='light']) .shot-dark {
display: block;
}
}
:root[data-theme='dark'] .shot-light {
display: none;
}
:root[data-theme='dark'] .shot-dark {
display: block;
}Both files are fetched whichever one is shown — display: none does not cancel the download — so
this costs the visitor a second screenshot. The drawing exists to avoid the pair.
astro:assets is the only image path in the theme: a raw <img> skips the sizing and the format
conversion, and public/ skips the hash, so the file is never cached hard.
Documentation
src/config.ts, the docs object:
groups— the sidebar groups in reading order. A page’sgroupmust be one of these; anything else falls to the end. The number in front of a group is counted from this list, so renaming or reordering a group touches no content file. An empty group is not printed.editUrl— the base for “Edit this page”; the page’s filename is appended. An empty string hides the link.
Ordering inside a group is the order field on the page, and the previous/next pager follows the
same sequence the sidebar prints.
Blog
src/config.ts, the blog object:
postsPerPage— page size for/blogand the tag pages.relatedPosts— how many “Read next” rows sit at the foot of a post.0removes the block. The order is shared tags first, then recency; parts of the same series are left out, because they are already listed at the top of the post.showReadingTime— the “6 min” in the byline. Minutes at 230 words, rounded up.
Pricing
The plans are content, not markup: src/content/plans.json, one object per column, order left
to right. See content.md for the fields.
The monthly/yearly switch on /pricing is PlanPeriod, a <fieldset> with two radios and no
client JavaScript. Inside the slot, mark each price with data-period="monthly" or
data-period="yearly"; anything unmarked (the name, the tagline, the feature list, the button)
shows under both. The saving line is computed from the two prices — twelve months against the
yearly figure — and never stored.
PlanMatrix prints the matrix object of every plan as rows: the row labels are the union of the
keys, in the order the plans list them, so you add a row by writing it into one plan. A plan that
never mentions a row prints an em dash rather than claiming a no. Booleans print as Yes / No
rather than ticks, because a glyph in a column that scrolls has to be read twice.
Search
Pagefind indexes the built site — marketing pages, documentation and posts in one index — and
serves /search plus the header dialog (⌘K). It is part of pnpm build, so there is no search
in pnpm dev: build, then pnpm preview.
- What is indexed is
<main>on any page that is notnoindex. The sidebar, the contents, the code-tab labels and the related-post rows carrydata-pagefind-ignore: they repeat on every page, and an excerpt that starts with the navigation is no excerpt at all. Add the attribute to anything of yours that repeats. - A page that is only a list of other pages passes
search={false}toBase— page four of the index and the tag pages do. The posts themselves are already in there. - The kicker on a result row comes from the URL. The table is
SECTIONSinsrc/components/pro/SearchPanel.astro; add a line when you add a section of the site, not when you add a single page. vite.build.rollupOptions.externalinastro.config.mjsis what keepsimport("/pagefind/pagefind.js")written as it stands. Removing it does not fail the build: it makes search fail silently at runtime.
Share cards (OG images)
seo.ogImages draws one card per post and per documentation page at build time, into dist/og/.
The title, the kicker and the colours come from the page and from your own stylesheets, so a
preset or an override in theme.css moves the cards with it. Nothing reaches the browser but the
URL.
Turn it off and every page falls back to site.defaultOgImage — see the note in Site identity
about putting a file there. The two faces used for the cards are latin subsets, the same as the
webfonts: a title in another script keeps the default card.
Long-form components
Available in any .mdx file under src/content/. Import them at the top of the file; they are
not injected globally, because a file that uses none should say so by importing none.
import Callout from '../../components/pro/Callout.astro';
import CodeTabs from '../../components/pro/CodeTabs.astro';
import ParamTable from '../../components/pro/ParamTable.astro';<Callout kind="note|warning" title="…">— the aside a manual needs. The kind decides the colour of the label and of the rule beside it; the word itself istitle, so “Deprecated” or “Before you start” needs no new kind.<CodeTabs labels={['curl', 'JavaScript']} legend="Choose a language">with one<Fragment slot="curl">per label, each holding a fenced block. Two to five labels; more than that is a list, not a choice. A radio group, so the arrow keys work and the hidden panels are out of the tab order.<ParamTable rows={[{ name, type, default, required, description }]} caption="…" />— the parameter table as data. A row has adefaultorrequired: true, never both.
Code fences take two optional pieces of metadata, in either order:
```toml title="~/.config/quarry/config.toml" {2,5-7}title is the caption on the block; {…} marks lines, listed with commas and ranged with a
hyphen. Every block gets a copy button, written by a script after the page loads, so a block on a
page with no JavaScript is a block with no dead button on it.
pnpm format does not touch src/content/**/*.mdx (see .prettierignore). Prettier’s Markdown
printer reflows a fenced block that sits inside a JSX element and turns the tabs above into one
line of shell. Set those files by hand.
Contact form
src/config.ts, the contact object. formEndpoint is where /contact posts: any service that
takes a plain form POST works — Formspree, Web3Forms, Basin. Replace the id and nothing else on
the page changes. email is printed beside the form for people who would rather write, and
responseTime is the sentence under it.
Analytics
src/config.ts, the analytics object. Nothing is loaded until both provider and id are set,
so the default build ships no third-party script. provider is 'plausible', 'ga4' or
'umami'; host points at your own origin for a self-hosted Plausible or Umami and is ignored by
GA4. The tag is rendered by src/components/common/Analytics.astro, at the end of <head>.
If you turn it on, say so on /privacy: the page as shipped states that no analytics script is
loaded.
SEO
src/config.ts, the seo object: titleTemplate (%s is the page title), twitterHandle, and
jsonLd, which is the publisher block — type is 'Organization' or 'Person'. Everything else
in <head> is src/components/common/SEO.astro; pages pass a title and a description, and the
rest is derived.
Favicon
public/favicon.svg and public/favicon.ico. Both are referenced from Base.astro.