Skip to content
Quarryv1.4

    Start free

    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 and robots.txt all print absolute URLs from it.
    • locale — a BCP 47 tag. It is the lang attribute, and it decides how dates and prices are formatted (Intl). The words the theme prints are in ui; 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: with seo.ogImages on, 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 in public/ (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.

    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 a title and a list of links.
    • nav.social — icon links. icon is a Lucide name (lucide:github, see lucide.dev/icons). Your own SVG goes in src/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. --surface is the tint on code blocks, callouts and panels; --faint is 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-width and --rail-gap (the left margin the index lives in). --shell-width is 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.
    • note is a second line in the rail (the changelog puts the release date there) and flag is the same line in --danger.
    • as is the label’s element. It is a <p> by default; on the changelog, where the version is the outline of the page, it is as="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’s group must 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 /blog and the tag pages.
    • relatedPosts — how many “Read next” rows sit at the foot of a post. 0 removes 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.

    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 not noindex. The sidebar, the contents, the code-tab labels and the related-post rows carry data-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} to Base — 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 SECTIONS in src/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.external in astro.config.mjs is what keeps import("/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 is title, 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 a default or required: 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.