How a "preset" (a named bundle of design tokens, type, and optional assets) is
chosen, applied, and changed. A preset is the starterkit's analogue of a theme
"skin", renamed to avoid collision with the Drupal theme and Tailwind's
@theme.
A complete set of Tailwind v4 design tokens — an @theme block of CSS custom
properties (colors, --font-sans, spacing, the reading surface). Two forms:
presets/minimal.css # simple: just the @theme tokens
presets/geometric/
├── preset.css # the @theme tokens
└── fonts.json # optional: self-hosted webfonts (pinned URL + sha256)
The shipped presets are minimal, corporate (both simple form), and
geometric (directory form, self-hosted font). minimal is the default
fallback.
Tailwind v4 compiles @theme at build time. Utility classes (bg-primary,
font-sans) bake the token values when npm run build runs. So a preset cannot
be a pure runtime "reference" — changing a preset always requires a rebuild.
That single fact drives the whole design below.
Applying a preset is a Vite/Tailwind build concern, and it must keep working
after Quick has removed itself (dq:cleanup). So the apply logic
lives in the theme, not in a Drush command:
npm run preset [<name>](scripts/preset.mjs, pure Node — globalfetchnode:crypto, no deps) is the single source of truth. It writes the chosen preset's tokens into thedq:presetblock ofsrc/main.css, layerspresets/overrides.css, fetches any preset fonts on demand (see Assets below), records the active preset inpackage.json, then rebuilds. Re-runnable forever.
dq:scaffolddoes the initial apply by simply calling that script (npm run preset -- <name>); it never re-implements token logic. Its only preset-specific job is translatingconfig.dq.yml'stheme_designinto the persistedpresets/overrides.css(viadesignTokenName()).
This is the resolution of "baked-in vs. changeable": initial apply by Quick, ongoing changeability by the theme.
Because fonts and dist/ are gitignored (see Assets), a bare npm run build
on a fresh clone would otherwise be missing them. A prebuild hook
(node scripts/preset.mjs --sync) runs before every build and re-applies the
active preset — re-fetching fonts and regenerating main.css — so npm run build alone always produces a correct, self-contained result. The active preset
is persisted to package.json dq.activePreset (written whenever npm run preset runs), so it is committed with the site and reproduced on any clone.
Unlike recipes, every preset lives inside the one already-installed starterkit package — there is no pre-install enumeration problem, so there is no registry/generator (the recipe-registry machinery does not apply here).
- The starterkit's
package.jsondeclaresdq.presets(the list) anddq.defaultPreset(the fallback) — the single source of truth.package.jsonis the theme's manifest and survivesgenerate-theme, sodq:scaffold(interactive menu, viadiscoverPresets()),bin/dq-init, andnpm run presetall read the same block. It travels intact, so there is no filesystem scan; a hardcoded['minimal', 'corporate']is the last resort if the manifest can't be read. - Fallback chain when no preset is named:
dq.defaultPreset→minimal. If a named preset has no token file, the script warns and falls back tominimal(or the first available). Resilient by default — an unset or unknown preset never breaks the build.
A preset is a complete token set (each preset re-states the reading-surface
tokens, so a preset may change them). Resolution, written into the dq:preset
block:
chosen preset ← presets/overrides.css (theme_design from config.dq.yml)
overrides.css persists, so it survives re-skinning: npm run preset corporate
keeps the user's theme_design tweaks on top of the new preset. In a generated
site it is committed (the starterkit gitignores it only for its own repo
hygiene, and deliberately withholds its .gitignore from generate-theme so
the site's root rules govern) — it cannot be regenerated once dq:cleanup
removes config.dq.yml.
Recipes ship their own templates, yet a preset's type scale and negative space must reach them — an airy preset must make article titles and project titles alike larger and looser without either recipe knowing which preset runs. The mechanism is a small contract vocabulary on top of the color/font tokens:
| Token | Utility | Meaning |
|---|---|---|
--text-title (+ --line-height) |
text-title |
content-item titles in listings |
--text-meta (+ --line-height) |
text-meta |
dates, captions, keyword lists |
--spacing-flow |
gap-flow, py-flow, … |
space between content units |
--spacing-row |
py-row |
vertical rhythm of a list row |
Two rules make it work:
- Preset authors: every preset MUST define the full contract (it is part of
"a preset is a complete token set"). Differentiate through it —
geometricships larger--text-titleand a wider--spacing-flow. - Recipe authors: templates MUST use the contract utilities for type scale
and inter-unit spacing — never raw
text-sm/gap-6. Colors already flow throughtext-ink/text-muted/bg-ruleetc. Anything hardcoded is invisible to presets.
theme_design can override contract tokens too: a key like text_title maps
to --text-title via the generic kebab rule, so
theme_design: { text_title: "1.25rem" } works with no special casing.
The directory form lets a preset self-host webfonts without committing any
binary. presets/<name>/fonts.json pins each font's download URL (a Google
Fonts / gstatic woff2) and sha256. On apply, the script:
- downloads each font into
src/fonts/(gitignored) only when that preset is applied, verifying it against the pinnedsha256— reusing any cached copy, so a repeat apply doesn't re-fetch; - generates the
@font-facerules from the manifest into a manageddq:preset-extrablock inmain.css, clearing it when switching back to a token-only preset; - writes a matching
<link rel="preload">per font into a manageddq:preset-preloadblock intemplates/layout/html.html.twig— browsers don't fetch a font just because its@font-facerule was parsed, they wait until they need it for visible text, so this starts the fetch immediately instead and shrinks the flash-of-unstyled-text window the manifest'sfont-display: swapdefault allows. A fontless preset leaves the block empty; and - prunes any files in
src/fonts/the active preset no longer needs.
The font is bundled into dist/ by Vite, so the built site is self-contained
(no external font request at render time — good for the Tome static export). The
trade-off: a first build needs network access, on the assumption you build
right after cloning. Nothing font-shaped ships in the repo, and an unused
directory-preset costs only a little CSS + JSON.
// presets/geometric/fonts.json
{ "fonts": [{
"family": "Geom", "weight": 400, "style": "normal", "display": "swap",
"file": "Geom-latin.woff2",
"url": "https://fonts.gstatic.com/s/geom/v1/…woff2",
"sha256": "ae1748bec04bf4e0…"
}] }Grab the woff2 URL + hash from google-webfonts-helper.
Copy presets/minimal.css to presets/<name>.css and edit, or create
presets/<name>/preset.css (+ fonts.json) for the self-hosted-font form. Add
the name to package.json dq.presets (that list is authoritative for
discovery), then npm run preset <name>.
- Presets are theme-owned, self-contained token sets; discovery is just reading
the installed starterkit's
package.json— no registry. - Switching is a build operation (
npm run preset), inherent to Tailwind v4. - The apply script is the single source of truth and survives Quick's
removal;
dq:scaffoldcalls it for the initial apply. - A resilient default fallback (
minimal) means a missing/unknown preset never breaks scaffolding.