- Summary
- Basic Workflow
- Advanced Workflow
- Modules
- Color
- Content
- Rules
- 1. Text
- 1a. Body uses regular and bold weights with a sans-serif system fallback
- 1b. Headings use light and black weights with a serif system fallback
- 1c. Size is bidirectional in rem units with an em inversion
- 1d. Leading is ranged with a root of 1.5 and a floor of 1.2
- 1e. Measure is ranged with a root of
75ch
and a floor of =48ch=
- 2. Spacing
- 3. Grid
- 4. Lengths
- 1. Text
- Result
- Rules
- Tokens
- =tokens.js= Output
- Modular Workflow
- Exporting Tokens
- Next Steps
This document is a practical guide to QuarkSuite Core. It’s intended to introduce you, the reader, to the library and its purpose from a user perspective. For a technical overview, refer to the API.
This document intentionally skips setup and environment details (see the README) to focus instead on a working example.
We’ll be covering three workflows in order of complexity:
- Basic
- Advanced
- Modular
The basic workflow will show you simple token generation and collection assembly. From there, you’ll get into the advanced usage of defining rules and processes to scale collections. The modular workflow will show you how to distribute your rules and token structures to share with other projects.
By the end of this document, I hope you learn a little something about how you can use QuarkSuite to create consistent, accessible baselines for your web projects.
If any part of the handbook is hard to understand, please open an issue and let me know.
QuarkSuite allows a lot of flexibility in how you can work, but for our purposes, we’ll begin by creating a single file named
tokens.js
and build our baseline up from there.
For our project, we’ll need specific functionality from the color.js
and content.js
modules.
import {
adjust,
convert,
harmony,
palette,
a11y
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import {
grid,
scale,
text,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/content.js";
The first set of data we’ll create is our color palette.
Let’s begin by setting a base color and slightly adjusting it for our use.
const swatch = convert("rgb", "#7ea");
const base = adjust({ chroma: -5, hue: 60 }, swatch);
Next, we want to generate an analogous color harmony as the basis of our palette generation.
const scheme = harmony({ configuration: "analogous" }, base);
The next step is to generate palettes for our desired contexts. In this case, we’ll create a main
and accent
palette
from the first and second colors in our scheme.
Assuming our web project is an app, we’ll want to generate a material
configuration.
We’ll also limit our palette to the most accessible colors for our contexts. In our example, that’s AA large adherence
for the ui
and a AA adherence for the body
text.
const main = palette({
configuration: "material",
contrast: 95,
accents: true,
states: true
}, scheme[0]);
const accent = palette({
configuration: "material",
contrast: 95,
accents: true,
}, scheme[1]);
const ui = a11y({ mode: "standard", rating: "AA", large: true }, main);
const body = a11y({ mode: "standard", rating: "AA" }, accent);
The main concern for our content at this point is to set a global ratio
and values
that the content modular scales
can use to generate their data later.
You see that we want our ratio to be 1.32
and for our scales to output 4
values.
const ratio = 1.32;
const values = 4;
Now we’re ready to actually create our tokens. For that, we’ll create a token object to export. Let’s wrap the tokens
in a namespace of hb
(for handbook) before slotting and transforming our data sets.
export default {
hb: {
color: {
ui,
text: body,
splash: scheme[2],
},
text: {
body: text({ system: "sans", weights: ["regular", "bold"] }, "Work Sans"),
heading: text(
{ system: "serif", weights: ["light", "black"] },
"Work Sans",
),
size: scale(
{
configuration: "bidirectional",
inversion: "em",
ratio,
values,
},
"1rem",
),
leading: scale(
{ configuration: "ranged", floor: 1.2, ratio, values },
1.5,
),
measure: scale(
{
configuration: "ranged",
floor: "48ch",
trunc: true,
ratio,
values,
},
"75ch",
),
},
spacing: scale({ configuration: "bidirectional", ratio, values }, "1ex"),
grid: grid({ ratio, rows: 3 }, values),
lengths: {
width: scale(
{
configuration: "ranged",
floor: "10vw",
ratio,
values,
},
"100vw",
),
height: scale(
{
configuration: "ranged",
floor: "10vh",
ratio,
values,
},
"100vh",
),
smallest: scale(
{
configuration: "ranged",
floor: "10vmin",
ratio,
values,
},
"100vmin",
),
largest: scale(
{
configuration: "ranged",
floor: "10vmax",
ratio,
values,
},
"100vmax",
),
},
},
};
Our tokens.js
file should now look something like the following code.
import {
adjust,
convert,
harmony,
palette,
a11y
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import {
grid,
scale,
text,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/content.js";
const swatch = convert("rgb", "#7ea");
const base = adjust({ chroma: -5, hue: 60 }, swatch);
const scheme = harmony({ configuration: "analogous" }, base);
const main = palette({
configuration: "material",
contrast: 95,
accents: true,
states: true
}, scheme[0]);
const accent = palette({
configuration: "material",
contrast: 95,
accents: true,
}, scheme[1]);
const ui = a11y({ mode: "standard", rating: "AA", large: true }, main);
const body = a11y({ mode: "standard", rating: "AA" }, accent);
const ratio = 1.32;
const values = 4;
export default {
hb: {
color: {
ui,
text: body,
splash: scheme[2],
},
text: {
body: text({ system: "sans", weights: ["regular", "bold"] }, "Work Sans"),
heading: text(
{ system: "serif", weights: ["light", "black"] },
"Work Sans",
),
size: scale(
{
configuration: "bidirectional",
inversion: "em",
ratio,
values,
},
"1rem",
),
leading: scale(
{ configuration: "ranged", floor: 1.2, ratio, values },
1.5,
),
measure: scale(
{
configuration: "ranged",
floor: "48ch",
trunc: true,
ratio,
values,
},
"75ch",
),
},
spacing: scale({ configuration: "bidirectional", ratio, values }, "1ex"),
grid: grid({ ratio, rows: 3 }, values),
lengths: {
width: scale(
{
configuration: "ranged",
floor: "10vw",
ratio,
values,
},
"100vw",
),
height: scale(
{
configuration: "ranged",
floor: "10vh",
ratio,
values,
},
"100vh",
),
smallest: scale(
{
configuration: "ranged",
floor: "10vmin",
ratio,
values,
},
"100vmin",
),
largest: scale(
{
configuration: "ranged",
floor: "10vmax",
ratio,
values,
},
"100vmax",
),
},
},
};
The basic workflow is great for small projects that need a singular data set.
The cracks in this approach starts to show the second you want to work with multiple data sets.
The library provides a workflow.js
module to handle these advanced use cases. Its only purpose is altering the way
library functions work to unlock design patterns that will be valuable for the developer who needs to scale.
If the basic workflow is a bottom-up procedure where we assemble data from a known value, then advanced usage dictates a top-down set of rules for unknown values.
The first thing to do is import workflow.js
, so let’s do that now.
import {
adjust,
convert,
harmony,
palette,
a11y
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import {
grid,
scale,
text,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/content.js";
import {
preset,
process,
pipeline,
delegate
} from "https://cdn.jsdelvr.net/gh/quarksuite/[email protected]/workflow.js";
Now, what set of rules directs our color token generation? Think about it for a second before we go on.
const toRgb = preset(convert, "rgb");
const reduceChroma5 = preset(adjust, { chroma: -5 });
const shiftHueRight60 = preset(adjust, { hue: 60 });
const scaleToAnalogous = preset(harmony, { configuration: "analogous" });
const generateUiPalette = preset(palette, {
configuration: "material",
accents: true,
states: true
});
const generateTextPalette = preset(palette, {
configuration: "material",
accents: true
});
const filterUiContext = preset(a11y, {
mode: "standard",
rating: "AA",
large: true
});
const filterTextContext = preset(a11y, {
mode: "standard",
rating: "AA"
});
All of the above makes our actual color generation code read like an order.
“Convert
#7ea
to RGB. Reduce chroma by 5 and shift hue 60 degrees right. Next, scale the result to an analogous harmony. Then delegate the UI and text color token processes asmain
andaccent
. Leavesplash
alone.”
The output is identical to the basic procedure but expressed in a more declarative way.
const scheme = pipeline(
"#7ea",
toRgb,
reduceChroma5,
shiftHueRight60,
scaleToAnalogous
);
const [main, accent, splash] = delegate(
scheme,
process(generateUiPalette, filterUiContext),
process(generateTextPalette, filterTextContext),
undefined
);
Content modular scales are so simple compared to color that applying a top-down approach to them usually isn’t necessary. Let’s say we do it anyway, though. How would that look?
We’re going to reuse our global settings as well.
const ratio = 1.32;
const values = 4;
const bodyTokens = preset(text, { system: "sans", weights: ["regular", "bold"]});
const headingTokens = preset(text, { system: "serif", weights: ["light", "black"]});
const sizeTokens = preset(scale, { configuration: "bidirectional", inversion: "em", ratio, values });
const leadingTokens = preset(scale, { configuration: "ranged", floor: 1.2, ratio, values });
const measureTokens = preset(scale, {
configuration: "ranged",
floor: "48ch",
trunc: true,
ratio,
values
});
const spacingTokens = preset(scale, { configuration: "bidirectional", ratio, values });
const gridTokens = preset(grid, { ratio });
const lengthOpts = { configuration: "ranged", floor: 10, ratio, values };
const lengthTokens = [
preset(scale, lengthOpts),
preset(scale, lengthOpts),
preset(scale, lengthOpts),
preset(scale, lengthOpts),
];
Now we’ll delegate our content scale rules to generated scales. The text category has five subcategories.
const [body, heading] = delegate(
["Work Sans", "Work Sans"],
bodyTokens,
headingTokens
);
const [
size,
leading,
measure
] = delegate(
["1rem", 1.5, "75ch"],
sizeTokens,
leadingTokens,
measureTokens
);
Spacing is its own category.
const spacing = spacingTokens("1ex");
Then we want to generate our grid category.
const gridOut = gridTokens(values);
Finally, we apply the length rules to four subcategories.
const [width, height, smallest, largest] = delegate(
["100vw", "100vh", "100vmin", "100vmax"],
...lengthTokens
);
Since we’ve used a top-down approach, our token dictionary is going to look a little different. We’ve now effectively separated token generation behavior from token collection structure. This means it’s easier to restructure our collection as needed.
export default {
hb: {
color: { ui: main, text: accent, splash },
text: { body, heading, size, leading, measure },
spacing,
grid: gridOut,
lengths: { width, height, smallest, largest }
}
}
We’re done, so let’s take a final look at what we’ve done.
import {
adjust,
convert,
harmony,
palette,
a11y,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import {
grid,
scale,
text,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/content.js";
import {
preset,
process,
pipeline
delegate
} from "https://cdn.jsdelvr.net/gh/quarksuite/[email protected]/workflow.js";
const toRgb = preset(convert, "rgb");
const reduceChroma5 = preset(adjust, { chroma: -5 });
const shiftHueRight60 = preset(adjust, { hue: 60 });
const scaleToAnalogous = preset(harmony, { configuration: "analogous" });
const generateUiPalette = preset(palette, {
configuration: "material",
accents: true,
states: true
});
const generateTextPalette = preset(palette, {
configuration: "material",
accents: true
});
const filterUiContext = preset(a11y, {
mode: "standard",
rating: "AA",
large: true
});
const filterTextContext = preset(a11y, {
mode: "standard",
rating: "AA"
});
const scheme = pipeline(
"#7ea",
toRgb,
reduceChroma5,
shiftHueRight60,
scaleToAnalogous
);
const [main, accent, splash] = delegate(
scheme,
process(generateUiPalette, filterUiContext),
process(generateTextPalette, filterTextContext),
undefined
);
const ratio = 1.32;
const values = 4;
const bodyTokens = preset(text, { system: "sans", weights: ["regular", "bold"]});
const headingTokens = preset(text, { system: "serif", weights: ["light", "black"]});
const sizeTokens = preset(scale, { configuration: "bidirectional", inversion: "em", ratio, values });
const leadingTokens = preset(scale, { configuration: "ranged", floor: 1.2, ratio, values });
const measureTokens = preset(scale, {
configuration: "ranged",
floor: "48ch",
trunc: true,
ratio,
values
});
const spacingTokens = preset(scale, { configuration: "bidirectional", ratio, values });
const gridTokens = preset(grid, { ratio });
const lengthOpts = { configuration: "ranged", floor: 10, ratio, values };
const lengthTokens = [
preset(scale, lengthOpts),
preset(scale, lengthOpts),
preset(scale, lengthOpts),
preset(scale, lengthOpts),
];
const [body, heading] = delegate(
["Work Sans", "Work Sans"],
bodyTokens,
headingTokens
);
const [
size,
leading,
measure
] = delegate(
["1rem", 1.5, "75ch"],
sizeTokens,
leadingTokens,
measureTokens
);
const spacing = spacingTokens("1ex");
const gridOut = gridTokens(values);
const [width, height, smallest, largest] = delegate(
["100vw", "100vh", "100vmin", "100vmax"],
...lengthTokens
);
export default {
hb: {
color: { ui: main, text: accent, splash },
text: { body, heading, size, leading, measure },
spacing,
grid: gridOut,
lengths: { width, height, smallest, largest }
}
}
The more complex our token generation needs become, the more we’ll start identifying habits in our process. These habits will replicate over projects and it will become tedious to set up the boilerplate. The solution here is to automate our habits.
Wrapping them in functions is the simplest approach. That’s the one we’ll use.
A modular workflow involves shifting your rules and processes from active to passive behavior. Think about what remains constant and what changes, and then expose only those knobs.
At the modular level, it’ll also be a good idea to break away from the data-last architecture we’ve been using up until now. This will save us from defining defaults every time we invoke our custom functions.
You may have also noticed that tokens.js
is growing with each rule and process we define. Time to break things up.
First, we’ll create a new file named color-recipe.js
.
The key to refactoring our color token generation is to identify the variables and turn them into knobs without changing the meaning of our rules. How can we do that?
import {
convert,
harmony,
palette,
a11y
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import {
preset,
process,
pipeline,
delegate
} from "https://cdn.jsdelvr.net/gh/quarksuite/[email protected]/workflow.js";
We look at our constants:
- The palettes will always output with material configurations
- The palettes will always output with accents and interface states
- UI and text contexts will always be delegated to the first two indexes
- UI context will always be calibrated for AA large accessibility
- Text context will always be calibrated for AA accessibility
- Any remaining indexes are left untouched
const generateUiPalette = preset(palette, {
configuration: "material",
accents: true,
states: true
});
const generateTextPalette = preset(palette, {
configuration: "material",
accents: true
});
const filterUiContext = preset(a11y, {
mode: "standard",
rating: "AA",
large: true
});
const filterTextContext = preset(a11y, {
mode: "standard",
rating: "AA"
});
And derive a recipe from our variables:
- The color won’t always have its properties adjusted
- The color won’t always scale to an analogous harmony
- The output won’t always be in RGB format
export default function(color, scheme = "analogous", format = "rgb") {
const setFormat = preset(convert, format);
const setScheme = preset(harmony, { configuration: scheme });
const base = pipeline(color, setFormat, setScheme);
return delegate(
Array.isArray(base)
? base
: [base, base],
process(generateUiPalette, filterUiContext),
process(generateTextPalette, filterTextContext),
undefined,
undefined
);
}
First, create a new file named content-recipes.js
.
Now we’re going to modularize one category at a time.
import {
grid,
scale,
text
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/content.js";
import {
preset,
delegate
} from "https://cdn.jsdelvr.net/gh/quarksuite/[email protected]/workflow.js";
- Body family always outputs with regular and bold weights
- Heading family always outputs with light and black weights
- Size is always bidirectional in rems with em inversion
- Leading is always a unitless range
- Measure is always ranged in ch
- Body system fallback is not always sans-serif
- Heading system fallback is not always serif
- The default minimum and maximum leading is not always a good fit
- The default minimum and maximum measure is not always a good fit
export function Font(font, bodyFallback = "sans", headingFallback = "serif") {
const bodyTokens = preset(text, { system: bodyFallback, weights: ["regular", "bold"] });
const headingTokens = preset(text, { system: headingFallback, weights: ["light", "black"] });
return delegate([font, font], bodyTokens, headingTokens);
}
const ratio = 1.32;
const values = 4;
export function Content([size, leading, measure], measureFloor = 48, leadingFloor = 1.2) {
const sizeTokens = preset(scale, {
configuration: "bidirectional",
inversion: "em",
ratio,
values
});
const leadingTokens = preset(scale, {
configuration: "ranged",
floor: leadingFloor,
ratio,
values
});
const [measureMin, measureMax] = measure;
const measureTokens = preset(scale, {
configuration: "ranged",
floor: measureFloor,
trunc: true,
ratio,
values
});
return delegate([size, leading, measure], sizeTokens, leadingTokens, measureTokens);
}
- The spacing will not always be in ex
export function Spacing(root) {
return scale({ configuration: "bidirectional" }, root);
}
- The grid will not always be 4 columns/rows
export function Grid(columns = 4, rows = columns) {
return grid({ ratio, rows }, columns);
}
- The output will always be viewport relative corresponding with dimensions
- The output will not always need every dimension
- The default minimum and maximum length is not always a good fit
export function Dimensions(
root,
dimensions = ["width", "height", "min", "max"],
floor = 10
) {
const lengthOpts = { configuration: "ranged", floor, ratio, values };
const targets = [
["width", "vw"],
["height", "vh"],
["min", "vmin"],
["max", "vmax"]
];
return targets
.filter(([context], index) => context === dimensions[index])
.map(([, length]) => scale(lengthOpts, String(root).concat(length)));
}
Finally, let’s reassemble our tokens using the recipes we just made.
import { adjust } from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/color.js";
import Palette from "./color-recipe.js";
import { Font, Content, Spacing, Grid, Dimensions } from "./content-recipes.js";
const [main, accent, splash] = Palette(adjust({
chroma: -5,
hue: 60
}, "#7ea"));
const [body, heading] = Font("Work Sans");
const [size, leading, measure] = Content(["1rem", 1.5, "75ch"]);
const spacing = Spacing("1ex");
const grid = Grid();
const [width, height, smallest, largest] = Dimensions(100);
export default {
hb: {
color: { ui: main, text: accent, splash },
text: { body, heading, size, leading, measure },
spacing,
grid,
lengths: { width, height, smallest, largest }
}
}
At this point, it’s important to note that it’s a good idea to keep your token generating code apart from your exporting code. This will allow you to tailor your exporting process to a given JavaScript engine. And this means you can safely adapt the exporting logic for different engines.
Example:
build.web.js
: when using the native webbuild.node.js
: when using Node.jsbuild.deno.js
: when using Denobuild.qjs.js
: when using QuickJSGenerally speaking, you will not need to export your tokens more than a few times during development, but I’m sure you can see the usefulness of this structure.
If your web project uses JavaScript itself to style your interface (such as a CSS-in-JS library): congratulations. You’re done already. Go forth and create.
For the rest of us, we’ll need to export our tokens to use in our target environments.
Since we’re still here, we’ll now create a build.js
file for the exporting process.
First, we have to pull in the exporters themselves before we can do anything.
import {
stylesheet,
data,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/exporter.js";
Next, we import the tokens we created in tokens.js
.
import tokens from "./tokens.js";
This step is crucial. Unless we wrap the tokens in an object that contains a project
property, the exporters will
throw an error. This is by design; it prevents us from accidentally invoking an exporter on arbitrary token
collections.
In this sense, project
works like a tag that tells an exporter “this is a complete dictionary. You may
proceed”. Otherwise, it’s “stop what you’re doing. Right now.”
We’ll store the token dictionary as dict
for later.
const dict = {
project: {
name: "Handbook Example Tokens",
author: "Chatman R. Jr",
license: "Unlicense",
version: "0.1.0"
},
...tokens
};
At this point, you should know that the exporter functions do not write to your filesystem. This is for security.
Instead, they format the token dictionary to a file-ready state which you can then write to a file yourself using your environment’s native API or a library.
Here’s the fun part. We’ll format our dictionary based on the domain targets.
In this case, we want to export our tokens as CSS custom properties and JSON. And let’s also store the results in
targets
.
As a bonus, exporters transform token collections in a dictionary recursively. This means the structure of your token collection is your choice.
const targets = {
css: stylesheet("css", dict),
json: data("json", dict)
};
Hint: if you’re using QuarkSuite server side and you’re exporting a single format, you can print the output of the exporter to the console and copy/paste or pipe the result to a new file.
Time to actually write the file to our OS. Let’s assume we’ve been building our tokens in Deno (v1.20.5) so far.
import { ensureDir } from "https://deno.land/[email protected]/fs/mod.ts";
const out = "./dist";
// This will create the output directory if it does not exist
await ensureDir(out);
Object.entries(targets).forEach(async ([ext, output]) => {
await Deno.writeTextFile(out.concat(`/tokens.${ext}`), output);
});
Finally, we run build.js
to create our export files.
deno run --allow-read --allow-write build.js
This will output ./dist
with our exported tokens.
dist
├── tokens.css
└── tokens.json
Our build file is now complete and we won’t need to touch it again for a good while.
import {
stylesheet,
data,
} from "https://cdn.jsdelivr.net/gh/quarksuite/[email protected]/exporter.js";
import tokens from "./tokens.js";
const dict = {
project: {
name: "Handbook Example Tokens",
author: "Chatman R. Jr",
license: "Unlicense",
version: "0.1.0"
},
...tokens
};
const targets = {
css: stylesheet("css", dict),
json: data("json", dict)
};
import { ensureDir } from "https://deno.land/[email protected]/fs/mod.ts";
const out = "./dist";
// This will create the output directory if it does not exist
await ensureDir(out);
Object.entries(targets).forEach(async ([ext, output]) => {
await Deno.writeTextFile(out.concat(`/tokens.${ext}`), output);
});
With that, we’ve reached the end of the handbook. Hopefully, you were able to follow along. You now know the core approaches to using and customizing QuarkSuite for your design token workflow. I didn’t cover everything, but I want to think I’ve provided a good foundation for the experimental reader to build on.
If I missed the mark, submit an issue as always.
If you’ve read the handbook but you haven’t read the API, I recommend doing that because it expands on the technical details not clarified here.
To those who have already read both documents by now: thank you for your time. I hope you got something out of it even if you don’t use the library.