Public
Edited
Jan 20, 2023
Importers
A Dynamic Design Token System
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
const tokens = {
Blue: "#198ebe",

Background: "#FFFFFF",
Focused: "#FFFF00",

// Focused has luminance boosted by 15%
focused: (_) => _.color.tint(_.color.luminance + 0.15),

// Border is 50% grey, 0.25 opaque
border: (_) => _.color.grey(0.5).alpha(0.25),

// Background is 75% blend to background color
background: (_) => _.color.blend(_("Background"), 0.75)
};
return Swatches(
["Blue", "Blue.focused", "Blue.border", "Blue.background"].map(
(_) => token(_).eval(tokens).color
)
);
}
Insert cell
Insert cell
Insert cell
{
const tokens = {
Red: "#FF0000",

// Ln: n between 0-10 will create a corresponding tint with stable luminance steps
L: (_, factor) => _.color.tint(clamp(prel(factor, -1, 10), 0, 1))
};
return ColorScale(
range(10).map((_) => token(`Red.L${_}`).eval(tokens).value)
);
}
Insert cell
{
const tokens = {
Primary: "#198ebe",
Highlight: "#FFFF00",

// Hn: n between 0-10, creates a luminance and color blend to the highlight color
H: (_, factor) =>
_.color
.blend(_("Highlight"), prel(factor, 0, 10))
.tint(prel(factor, -1, 10))
};
return ColorScale(
range(10).map((_) => token(`Primary.H${_}`).eval(tokens).value)
);
}
Insert cell
Insert cell
Insert cell
{
const tokens = {
// We define a primary colour
Primary: "#198ebe",

// We define a normal background
Background: "#FFFFFF",
Text: "#000000",

// We define a dark mode
Dark: {
Background: "#000000",
Text: "#FFFFFF"
},

// Zn, n in 0-10: Z0 makes the color the same luminance as the background, Z9 as the foreground.
Z: (_, factor) => {
const bg = _("Background");
const fg = _("Text");
const k = prel(factor, 0, 10);
const l = lerp(bg.luminance, fg.luminance, k);
return _.color.tint(l, bg, fg);
}
};
return ColorScale(
["Background", "Dark.Background", "Primary.Z8", "Primary.Dark.Z8"].map(
(_) => token(_).eval(tokens).color
)
);
}
Insert cell
Insert cell
Insert cell
Insert cell
RE_TOKEN = new RegExp(
"(\\.(?<key>[A-Za-z_]+)(?<factor>\\d+)|(?<value>\\d+(\\.\\d+)?)(?<unit>[A-Za-z]+)|(?<name>[A-Za-z_]+))",
"g"
)
Insert cell
token = (text) => {
const r = [];
while (true) {
const m = RE_TOKEN.exec(text);
if (!m) {
break;
} else if (m.groups.key !== undefined) {
r.push({
type: "factor",
name: m.groups.key,
factor: parseInt(m.groups.factor)
});
} else if (m.groups.name !== undefined) {
r.push({ type: "symbol", name: m.groups.name });
} else if (m.groups.value !== undefined) {
r.push({ type: "metric", factor: m.groups.value, name: m.groups.unit });
} else {
throw new Error(`Unexpected match: ${m}`);
}
}
return new Token(r);
}
Insert cell
token("Red.L10")
Insert cell
class Token {
constructor(steps) {
this.steps = [...steps];
}

// This is the main function
eval(context = {}) {
// The resolver becomes a function that gets assigned the state, while resolving
// The context.
const resolver = (
name = "value",
factor = undefined,
state = undefined
) => {
const value =
state && state[name] !== undefined ? state[name] : context[name];
if (typeof value === "string") {
return { value: color(value) };
} else if (typeof value === "function") {
// TODO: We may want to merge in the rest of the context, maybe not only the value?
factor = factor ? parseFloat(factor) : factor;
const res = value(
// NOTE: We copy the state in the function so that the context is accessible
Object.assign(
(name, factor) => resolver(name, factor, state).value,
state
),
factor
);
return {
value: res
};
} else if (typeof value === "object") {
return (({ value, ...rest }) => rest)(value);
} else {
throw new Error(`Undefined token '${name}'`);
}
};

return this.steps.reduce((r, v, i) => {
const { type, name, factor } = v;
const { value, ...rest } = resolver(name, factor, r);
return Object.assign(r, {
color: value instanceof Color ? value : r.color,
value,
...rest
});
}, {});
}
}
Insert cell
Insert cell
Insert cell
color = (_) => Color.Ensure(_)
Insert cell
class Color {
static BLACK = new Color([0, 0, 0, 1]);
static WHITE = new Color([1, 1, 1, 1]);

static DarkLight(black = undefined, white = undefined) {
white = Color.Ensure(white || Color.WHITE);
black = Color.Ensure(black || Color.BLACK);
const dark = black.luminance < white.luminance ? black : white;
const light = dark === black ? white : black;
return [dark, light];
}

static Ensure(value, strict = true) {
return value instanceof Color
? value
: value instanceof Array ||
(typeof value === "string" && value.match(RE_COLOR))
? new Color(value)
: strict
? (() => {
throw new Error(
`Could not decode color from ${typeof value}: ${value}`
);
})()
: null;
}

constructor(value) {
if (!(value instanceof Array || typeof value === "string")) {
throw new Error(
`Color value should be RGBA array or hex string, got ${typeof value}: ${value}`
);
}
this.value = value instanceof Array ? value : hex(value);
if (this.value.reduce((r, v) => (isNaN(v) ? r + 1 : r), 0)) {
throw new Error(
`Could not create color from: ${value}, got NaN in ${this.value}`
);
}
if (!this.value) {
throw new Error(`Could not create color from: ${value}`);
}
while (this.value.length < 4) {
this.value.push(1.0);
}
}

get rgb() {
const [r, g, b] = this.value.map((_) => Math.round(gamma(_) * 255));
return `rgb(${r},${g},${b})`;
}

get hex() {
return hex(this.value);
}

get lightness() {
// SEE: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_Colors_and_Luminance
// Ref: "Another way to describe this is that our perception roughly follows a power curve with an exponent of ~0.425, so perceptual lightness is approximately L* = Y0.425, though this depends on adaptation."
return Math.pow(this.luminance, 0.425);
}

get luminance() {
const [r, g, b] = this.value;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// Returns a version of the current color that contrast (in luminance) with
// the other color of `delta` (between 0 and 1). The actual luminance delta
// may be lower if it's not possible to make the luminance greter.
contrast(other, delta = 0.1) {
const la = this.luminance;
const lb = Color.Ensure(other).luminance;
const ld = clamp(lb - la, 0, 1);
return Math.abs(ld) === delta
? this
: this.tint(clamp(la + delta * (ld / Math.abs(ld)), 0, 1));
}

// Creates a derived color with the given alpha value.
alpha(value = 0.5) {
const res = new Color([...this.value]);
res.value[3] = clamp(value);
return res;
}

// Returns a grey version of this color
grey(k=1) {
const l = this.luminance;
const g = new Color([l, l, l, 1])
return k >= 1 ? g : this.blend(g, k);
}

tint(luminance = 0.5, black = undefined, white = undefined) {
const [dark, light] = Color.DarkLight(black, white);
const l = clamp(this.luminance, 0, 1);
const v = clamp(luminance, 0, 1);
// If the current luminance is less than the target luminance
return l < v
? // Then we blend to white of a factor that the difference in
this.blend(light, prel(v, l, light.luminance))
: this.blend(dark, prel(v, l, dark.luminance));
}

scale(steps = 10, black = undefined, white = undefined) {
const [dark, light] = Color.DarkLight(black, white);
return new Array(steps + 1)
.fill(0)
.map((_, i) => this.tint(i / steps, dark, light));
}

blend(color, k = 0.5) {
const c = Color.Ensure(color, true);
return new Color(
this.value.map((v, i) => clamp(lerp(v, c.value[i], k), 0, 1))
);
}

// TODO: Grey
toString() {
return hex(this.value);
}
}
Insert cell
Insert cell
Insert cell
ColorScale(color("#FF0000").scale(10))
Insert cell
Insert cell
Insert cell
color("#FF0000")
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
gamma([1.0, 1.0, 1.0])
Insert cell
Insert cell
Insert cell
Insert cell
lerp = (a, b, k) => a + (b - a) * k
Insert cell
clamp = (v, a = 0.0, b = 1.0) => Math.min(Math.max(v, a), b)
Insert cell
minmax = (a, b) => [Math.min(a, b), Math.max(a, b)]
Insert cell
range = (count) => new Array(count).fill(0).map((_, i) => i)
Insert cell
prel = (v, a, b) => (v - a) / (b - a)
Insert cell
steps = (count) =>
range(count)
.map((_) => _ / count)
.concat([1.0])
Insert cell
Insert cell
asMappable = (f) => (_) => _ instanceof Array ? _.map(f) : f(_)
Insert cell
def = (...rest) => {
for (let v of rest) {
if (v !== undefined) {
return v;
}
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more