@halftone-ds/tokens 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -4
- package/cli.mjs +28 -12
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +23 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +31 -3
- package/lib/theme/apply.mjs +17 -1
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/fonts.mjs +3 -1
- package/lib/theme/generate.mjs +27 -4
- package/lib/theme/index.d.mts +75 -0
- package/lib/theme/index.mjs +15 -2
- package/lib/theme/presets.mjs +18 -6
- package/lib/theme/recipe.mjs +39 -0
- package/lib/theme/semantic.mjs +25 -0
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +25 -0
- package/lib/theme-cli.mjs +98 -6
- package/package.json +1 -1
- package/targets/design-md.mjs +33 -3
- package/targets/llms.mjs +2 -1
- package/targets/nativewind3.mjs +22 -7
- package/targets/shadcn-web.mjs +12 -2
- package/targets/ts-theme.mjs +7 -0
package/lib/theme/codec.mjs
CHANGED
|
@@ -3,23 +3,26 @@
|
|
|
3
3
|
* read out, paste into a terminal or a URL.
|
|
4
4
|
*
|
|
5
5
|
* ht1- 12 characters, 60 bits: a theme with the default typeset (every code made before the typeset)
|
|
6
|
-
* ht2- 16 characters, 80 bits:
|
|
6
|
+
* ht2- 16 characters, 80 bits: a theme with the default styles (solid buttons, outline fields)
|
|
7
|
+
* ht3- 18 characters, 90 bits: any theme
|
|
7
8
|
*
|
|
8
|
-
* bits field ht1 ht2
|
|
9
|
-
* recipe version (RECIPE_VERSION) 4 4
|
|
10
|
-
* brand hex 24 24
|
|
11
|
-
* neutral, radius, controls 3+3+1 3+3+1 (option indexes, recipe.mjs)
|
|
12
|
-
* heading, body (, mono) 5+5 6+6+6 (indexes into FONTS, fonts.mjs)
|
|
13
|
-
* stroke, depth, density, border 2+2+2+2 2+2+2+2
|
|
14
|
-
* size, leading, flow, measure – 3+3+3+3
|
|
15
|
-
*
|
|
9
|
+
* bits field ht1 ht2 ht3
|
|
10
|
+
* recipe version (RECIPE_VERSION) 4 4 4
|
|
11
|
+
* brand hex 24 24 24
|
|
12
|
+
* neutral, radius, controls 3+3+1 3+3+1 3+3+1 (option indexes, recipe.mjs)
|
|
13
|
+
* heading, body (, mono) 5+5 6+6+6 6+6+6 (indexes into FONTS, fonts.mjs)
|
|
14
|
+
* stroke, depth, density, border 2+2+2+2 2+2+2+2 2+2+2+2
|
|
15
|
+
* size, leading, flow, measure – 3+3+3+3 3+3+3+3
|
|
16
|
+
* button, field – – 2+2
|
|
17
|
+
* reserved (zero: room for more styles) – – 6
|
|
18
|
+
* checksum (payload mod 127) 7 7 7 a typo is caught instead of making another theme
|
|
16
19
|
*
|
|
17
|
-
* encodeRecipe writes
|
|
18
|
-
* always had
|
|
20
|
+
* encodeRecipe writes the shortest code that holds the theme (ht1, then ht2, then ht3), so a theme keeps the
|
|
21
|
+
* code it always had and older halftone-tokens can read it. The preset name is not in the code: a code is the theme,
|
|
19
22
|
* whichever preset it started from.
|
|
20
23
|
*/
|
|
21
24
|
import { FONTS } from './fonts.mjs';
|
|
22
|
-
import { defaultTypeset, normalizeRecipe, OPTIONS, RECIPE_VERSION } from './recipe.mjs';
|
|
25
|
+
import { defaultStyles, defaultTypeset, normalizeRecipe, OPTIONS, RECIPE_VERSION } from './recipe.mjs';
|
|
23
26
|
|
|
24
27
|
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
25
28
|
const LAYOUT = [
|
|
@@ -39,18 +42,24 @@ const TYPESET = [
|
|
|
39
42
|
['flow', 3],
|
|
40
43
|
['measure', 3],
|
|
41
44
|
];
|
|
45
|
+
const STYLES = [
|
|
46
|
+
['button', 2],
|
|
47
|
+
['field', 2],
|
|
48
|
+
];
|
|
49
|
+
const RESERVED = 6;
|
|
42
50
|
/** Per format: prefix, characters, font width, whether the typeset is in it. */
|
|
43
51
|
const FORMATS = {
|
|
44
|
-
ht1: { prefix: 'ht1-', chars: 12, font: 5, typeset: false },
|
|
45
|
-
ht2: { prefix: 'ht2-', chars: 16, font: 6, typeset: true },
|
|
52
|
+
ht1: { prefix: 'ht1-', chars: 12, font: 5, typeset: false, styles: false },
|
|
53
|
+
ht2: { prefix: 'ht2-', chars: 16, font: 6, typeset: true, styles: false },
|
|
54
|
+
ht3: { prefix: 'ht3-', chars: 18, font: 6, typeset: true, styles: true },
|
|
46
55
|
};
|
|
47
56
|
|
|
48
57
|
const fontIndex = (id) => FONTS.findIndex((f) => f.id === id);
|
|
49
58
|
|
|
50
59
|
export function encodeRecipe(input) {
|
|
51
60
|
const r = normalizeRecipe(input);
|
|
52
|
-
const ht1 = defaultTypeset(r) && fontIndex(r.font.heading) < 32 && fontIndex(r.font.body) < 32;
|
|
53
|
-
const format = ht1 ? FORMATS.ht1 : FORMATS.ht2;
|
|
61
|
+
const ht1 = defaultStyles(r) && defaultTypeset(r) && fontIndex(r.font.heading) < 32 && fontIndex(r.font.body) < 32;
|
|
62
|
+
const format = ht1 ? FORMATS.ht1 : defaultStyles(r) ? FORMATS.ht2 : FORMATS.ht3;
|
|
54
63
|
let bits = BigInt(r.v);
|
|
55
64
|
const push = (value, width) => {
|
|
56
65
|
if (value < 0 || value >= 2 ** width) throw new Error(`encodeRecipe: ${value} does not fit in ${width} bits`);
|
|
@@ -63,6 +72,10 @@ export function encodeRecipe(input) {
|
|
|
63
72
|
if (format.typeset) push(fontIndex(r.font.mono), format.font);
|
|
64
73
|
for (const [key, width] of TAIL) push(OPTIONS[key].indexOf(r[key]), width);
|
|
65
74
|
if (format.typeset) for (const [key, width] of TYPESET) push(OPTIONS[key].indexOf(r[key]), width);
|
|
75
|
+
if (format.styles) {
|
|
76
|
+
for (const [key, width] of STYLES) push(OPTIONS[key].indexOf(r[key]), width);
|
|
77
|
+
push(0, RESERVED);
|
|
78
|
+
}
|
|
66
79
|
bits = (bits << 7n) | (bits % 127n);
|
|
67
80
|
|
|
68
81
|
let out = '';
|
|
@@ -76,13 +89,13 @@ export function encodeRecipe(input) {
|
|
|
76
89
|
/** Code → recipe. Throws on a malformed code, a bad checksum or an unknown version. */
|
|
77
90
|
export function decodeRecipe(code) {
|
|
78
91
|
const clean = String(code).trim().toUpperCase();
|
|
79
|
-
const format = /^HT2/.test(clean) ? FORMATS.ht2 : FORMATS.ht1;
|
|
80
|
-
const raw = clean.replace(/^HT[
|
|
81
|
-
if (!/^HT[
|
|
92
|
+
const format = /^HT3/.test(clean) ? FORMATS.ht3 : /^HT2/.test(clean) ? FORMATS.ht2 : FORMATS.ht1;
|
|
93
|
+
const raw = clean.replace(/^HT[123]-?/, '').replace(/[IL]/g, '1').replace(/O/g, '0').replace(/-/g, '');
|
|
94
|
+
if (!/^HT[123]/.test(clean) && /^HT\d/.test(clean)) {
|
|
82
95
|
throw new Error(`"${code}" is a newer theme code than this halftone-tokens reads. Update @halftone-ds/tokens.`);
|
|
83
96
|
}
|
|
84
97
|
if (!new RegExp(`^[0-9A-HJKMNP-TV-Z]{${format.chars}}$`).test(raw)) {
|
|
85
|
-
throw new Error(`"${code}" is not a theme code (expected ht1- and 12 characters, or
|
|
98
|
+
throw new Error(`"${code}" is not a theme code (expected ht1- and 12 characters, ht2- and 16, or ht3- and 18)`);
|
|
86
99
|
}
|
|
87
100
|
let bits = 0n;
|
|
88
101
|
for (const ch of raw) bits = (bits << 5n) | BigInt(ALPHABET.indexOf(ch));
|
|
@@ -106,7 +119,9 @@ export function decodeRecipe(code) {
|
|
|
106
119
|
return f.id;
|
|
107
120
|
};
|
|
108
121
|
const fields = (list) => Object.fromEntries([...list].reverse().map(([key, width]) => [key, opt(key, width)]));
|
|
109
|
-
// Read back to front.
|
|
122
|
+
// Read back to front. Reserved bits set means a newer halftone-tokens wrote styles this one doesn't know.
|
|
123
|
+
if (format.styles && pull(RESERVED) !== 0) throw new Error(`"${code}" is a newer theme code than this halftone-tokens reads. Update @halftone-ds/tokens.`);
|
|
124
|
+
const styles = format.styles ? fields(STYLES) : {};
|
|
110
125
|
const typeset = format.typeset ? fields(TYPESET) : {};
|
|
111
126
|
const tail = fields(TAIL);
|
|
112
127
|
const mono = format.typeset ? font(format.font) : undefined;
|
|
@@ -116,7 +131,7 @@ export function decodeRecipe(code) {
|
|
|
116
131
|
const brand = '#' + pull(24).toString(16).padStart(6, '0');
|
|
117
132
|
const v = Number(bits);
|
|
118
133
|
if (v !== RECIPE_VERSION) throw new Error(`"${code}" is a version ${v} theme; this halftone-tokens reads version ${RECIPE_VERSION}. Update @halftone-ds/tokens.`);
|
|
119
|
-
return normalizeRecipe({ v, brand, ...head, font: { heading, body, ...(mono ? { mono } : {}) }, ...tail, ...typeset });
|
|
134
|
+
return normalizeRecipe({ v, brand, ...head, font: { heading, body, ...(mono ? { mono } : {}) }, ...tail, ...typeset, ...styles });
|
|
120
135
|
}
|
|
121
136
|
|
|
122
|
-
export const isThemeCode = (value) => /^(ht1-?[0-9a-z]{12}|ht2-?[0-9a-z]{16})$/i.test(String(value).trim());
|
|
137
|
+
export const isThemeCode = (value) => /^(ht1-?[0-9a-z]{12}|ht2-?[0-9a-z]{16}|ht3-?[0-9a-z]{18})$/i.test(String(value).trim());
|
package/lib/theme/fonts.mjs
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* system the platform's own font for its role: nothing to load
|
|
14
14
|
* weights the static weights we load (400–700); a class asking for another snaps to the nearest
|
|
15
15
|
* expoPrefix the export prefix of the @expo-google-fonts package (Inter_600SemiBold → 'Inter')
|
|
16
|
+
* fontsource 'static' when only @fontsource/<id> exists; every other family has @fontsource-variable/<id>
|
|
17
|
+
* (for a shadcn project: lib/theme/shadcn.mjs shadcnFont)
|
|
16
18
|
*/
|
|
17
19
|
export const FONTS = [
|
|
18
20
|
{ id: 'system', family: 'System', category: 'sans', weights: [400, 500, 600, 700, 800], system: true },
|
|
@@ -35,7 +37,7 @@ export const FONTS = [
|
|
|
35
37
|
{ id: 'system-mono', family: 'Menlo', category: 'mono', weights: [400, 500, 600, 700], system: true },
|
|
36
38
|
{ id: 'jetbrains-mono', family: 'JetBrains Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'JetBrainsMono' },
|
|
37
39
|
{ id: 'geist-mono', family: 'Geist Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'GeistMono' },
|
|
38
|
-
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'IBMPlexMono' },
|
|
40
|
+
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'IBMPlexMono', fontsource: 'static' },
|
|
39
41
|
{ id: 'roboto-mono', family: 'Roboto Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'RobotoMono' },
|
|
40
42
|
{ id: 'fira-code', family: 'Fira Code', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'FiraCode' },
|
|
41
43
|
{ id: 'source-code-pro', family: 'Source Code Pro', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'SourceCodePro' },
|
package/lib/theme/generate.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
import { fixContrast } from './fix.mjs';
|
|
11
11
|
import { encodeRecipe } from './codec.mjs';
|
|
12
12
|
import { fontById } from './fonts.mjs';
|
|
13
|
-
import { BORDER, DENSITY, DEPTH, FLOW, LEADING, normalizeRecipe, RADIUS, STROKE, typeScale } from './recipe.mjs';
|
|
13
|
+
import { BORDER, BUTTON, DENSITY, DEPTH, FIELD, fieldWidth, FLOW, LEADING, normalizeRecipe, RADIUS, STROKE, typeScale } from './recipe.mjs';
|
|
14
14
|
import { anchorStep, brandRamp, neutralRamp } from './ramp.mjs';
|
|
15
15
|
import { resolvedMap } from './refs.mjs';
|
|
16
|
-
import { semanticColors } from './semantic.mjs';
|
|
16
|
+
import { semanticColors, styleColors } from './semantic.mjs';
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
const ramp = (values) => Object.fromEntries(Object.entries(values).map(([step, $value]) => [step, { $type: 'color', $value }]));
|
|
@@ -34,9 +34,17 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
34
34
|
return hex;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const anchor = anchorStep(recipe.brand);
|
|
38
|
+
let colors = semanticColors(anchor);
|
|
38
39
|
let adjustments = [];
|
|
39
40
|
if (fix) ({ semantic: colors, adjustments } = fixContrast(colors, lookup));
|
|
41
|
+
// The styles' colours build on the fixed ones, then get their own check (a soft button's ink, a field's line).
|
|
42
|
+
colors = { ...colors, ...styleColors(recipe, anchor, colors) };
|
|
43
|
+
if (fix) {
|
|
44
|
+
const styled = fixContrast(colors, lookup);
|
|
45
|
+
colors = styled.semantic;
|
|
46
|
+
adjustments = [...adjustments, ...styled.adjustments];
|
|
47
|
+
}
|
|
40
48
|
|
|
41
49
|
const radiusRef = RADIUS[recipe.radius];
|
|
42
50
|
const radiusValue = typeof radiusRef === 'string' ? `{primitive.radius.${radiusRef}}` : radiusRef;
|
|
@@ -57,6 +65,17 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
57
65
|
measure: Number(recipe.measure),
|
|
58
66
|
};
|
|
59
67
|
|
|
68
|
+
const borderWidth = BORDER[recipe.border];
|
|
69
|
+
const btn = BUTTON[recipe.button];
|
|
70
|
+
const fld = FIELD[recipe.field];
|
|
71
|
+
const button = { alpha: btn.alpha, border: btn.border, edge: btn.edge };
|
|
72
|
+
const field = { alpha: fld.alpha, border: fieldWidth(fld.border, borderWidth), edge: fieldWidth(fld.edge, borderWidth), inset: fld.inset };
|
|
73
|
+
// As tokens: widths named apart from the colours (semantic.color.button-edge is a colour).
|
|
74
|
+
const buttonTokens = { alpha: button.alpha, 'border-width': button.border, 'edge-width': button.edge };
|
|
75
|
+
const fieldTokens = { alpha: field.alpha, 'border-width': field.border, 'edge-width': field.edge, round: fld.round, 'round-bottom': fld.roundBottom, inset: field.inset };
|
|
76
|
+
// rounded-md is the radius × md / lg (resolve.mjs); a field's radius is that or square.
|
|
77
|
+
const md = lookup('{primitive.radius.md}') / lookup('{primitive.radius.lg}');
|
|
78
|
+
|
|
60
79
|
return {
|
|
61
80
|
recipe,
|
|
62
81
|
code: encodeRecipe(recipe),
|
|
@@ -70,13 +89,17 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
70
89
|
shadow: DEPTH[recipe.depth],
|
|
71
90
|
density: { scale: density.scale, control: { sm: density.sm, md: density.md, lg: density.lg, x: density.x } },
|
|
72
91
|
type,
|
|
92
|
+
button: buttonTokens,
|
|
93
|
+
field: fieldTokens,
|
|
73
94
|
},
|
|
74
95
|
density,
|
|
75
96
|
type: typeScale(type),
|
|
76
97
|
resolved,
|
|
77
98
|
radius,
|
|
78
99
|
radiusControl: lookup(controlRadius),
|
|
79
|
-
borderWidth
|
|
100
|
+
borderWidth,
|
|
101
|
+
button,
|
|
102
|
+
field: { ...field, radius: lookup(radiusValue) * md * fld.round, radiusBottom: lookup(radiusValue) * md * fld.roundBottom },
|
|
80
103
|
stroke: STROKE[recipe.stroke],
|
|
81
104
|
adjustments,
|
|
82
105
|
};
|
package/lib/theme/index.d.mts
CHANGED
|
@@ -11,7 +11,13 @@ export type Size = '16' | '14' | '15' | '18';
|
|
|
11
11
|
export type Leading = 'normal' | 'tight' | 'relaxed';
|
|
12
12
|
export type Flow = 'normal' | 'tight' | 'loose';
|
|
13
13
|
export type Measure = '70' | '60' | '80' | '90';
|
|
14
|
+
export type ButtonStyle = 'solid' | 'soft' | 'outline' | 'raised';
|
|
15
|
+
export type FieldStyle = 'outline' | 'filled' | 'underline';
|
|
14
16
|
export type Mode = 'light' | 'dark';
|
|
17
|
+
export type CvdType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
|
18
|
+
/** A pair of tones as one vision sees them: `fg` and `bg` are the simulated hex of `a` and `b`. */
|
|
19
|
+
export type PairDistance = { a: string; b: string; vision: CvdType | 'none'; fg: string; bg: string; deltaE: number };
|
|
20
|
+
export type CvdWarning = PairDistance & { mode: Mode };
|
|
15
21
|
|
|
16
22
|
export type Recipe = {
|
|
17
23
|
v: number;
|
|
@@ -28,6 +34,10 @@ export type Recipe = {
|
|
|
28
34
|
leading: Leading;
|
|
29
35
|
flow: Flow;
|
|
30
36
|
measure: Measure;
|
|
37
|
+
/** The primary button's style. */
|
|
38
|
+
button: ButtonStyle;
|
|
39
|
+
/** Text fields' style (inputs, text areas, select triggers). */
|
|
40
|
+
field: FieldStyle;
|
|
31
41
|
preset?: string;
|
|
32
42
|
};
|
|
33
43
|
export type RecipeInput = Partial<Omit<Recipe, 'font'>> & { font?: Partial<Recipe['font']> };
|
|
@@ -52,6 +62,10 @@ export type Theme = {
|
|
|
52
62
|
borderWidth: number;
|
|
53
63
|
stroke: number;
|
|
54
64
|
density: { sm: number; md: number; lg: number; x: number; scale: number };
|
|
65
|
+
/** The button style's numbers: fill opacity, border and bottom edge (px). */
|
|
66
|
+
button: { alpha: number; border: number; edge: number };
|
|
67
|
+
/** The field style's numbers: fill opacity per mode, border and bottom edge (px), radii (px), inset (0 or 1). */
|
|
68
|
+
field: { alpha: Record<Mode, number>; border: number; edge: number; inset: number; radius: number; radiusBottom: number };
|
|
55
69
|
[key: string]: unknown;
|
|
56
70
|
};
|
|
57
71
|
|
|
@@ -75,14 +89,20 @@ export const OPTIONS: {
|
|
|
75
89
|
leading: Leading[];
|
|
76
90
|
flow: Flow[];
|
|
77
91
|
measure: Measure[];
|
|
92
|
+
button: ButtonStyle[];
|
|
93
|
+
field: FieldStyle[];
|
|
78
94
|
};
|
|
79
95
|
export const TYPESET_KEYS: ['size', 'leading', 'flow', 'measure'];
|
|
96
|
+
export const STYLE_KEYS: ['button', 'field'];
|
|
80
97
|
export const LEADING: Record<Leading, number>;
|
|
81
98
|
export const FLOW: Record<Flow, number>;
|
|
82
99
|
export const FONTS: Font[];
|
|
83
100
|
export const PRESETS: Preset[];
|
|
84
101
|
export const BRAND_RAMPS: Record<string, Record<string, string>>;
|
|
85
102
|
export const THEME_EXTENSION: string;
|
|
103
|
+
export const CVD_TYPES: CvdType[];
|
|
104
|
+
export const DISTINCT_PAIRS: [string, string][];
|
|
105
|
+
export const MIN_DELTA_E: number;
|
|
86
106
|
|
|
87
107
|
export function normalizeRecipe(input?: RecipeInput): Recipe;
|
|
88
108
|
export function sameRecipe(a: RecipeInput, b: RecipeInput): boolean;
|
|
@@ -94,9 +114,64 @@ export function fontById(id: string): Font | undefined;
|
|
|
94
114
|
export function googleFontsHref(ids: string[]): string | null;
|
|
95
115
|
export function presetById(id: string): Preset | undefined;
|
|
96
116
|
export function defaultTypeset(recipe: Recipe): boolean;
|
|
117
|
+
export function defaultStyles(recipe: Recipe): boolean;
|
|
97
118
|
export function typeVars(type: TypeScale): Record<string, string>;
|
|
119
|
+
export function styleVars(styles: Pick<Theme, 'button' | 'field'>, format: 'hsl' | 'oklch'): Record<Mode, Record<string, string>>;
|
|
98
120
|
export function themeVars(theme: Theme, format: 'hsl' | 'oklch'): Record<Mode, Record<string, string>>;
|
|
99
121
|
export function themeHex(theme: Theme): Record<Mode, Record<string, string>>;
|
|
100
122
|
export function themeCss(theme: Theme, options?: { format?: 'hsl' | 'oklch'; light?: string; dark?: string }): string;
|
|
101
123
|
export function applyRecipe(tokens: TokensJson, recipe: RecipeInput): { tokens: TokensJson; theme: Theme };
|
|
102
124
|
export function themeStatus(tokens: TokensJson): ThemeStatus;
|
|
125
|
+
|
|
126
|
+
/** One pairing the contrast gate checks: `fg` on `bg` in one mode, its WCAG ratio and the threshold it must clear. */
|
|
127
|
+
export type ContrastPair = { mode: Mode; label: string; fgName: string; bgName: string; fg: string; bg: string; threshold: number; ratio: number };
|
|
128
|
+
/** A ContrastPair for a table: `pass` against its WCAG threshold, and `apca`, the advisory APCA Lc (signed). */
|
|
129
|
+
export type ContrastRow = ContrastPair & { pass: boolean; apca: number };
|
|
130
|
+
|
|
131
|
+
/** WCAG 2.1 AA for text (4.5) and for icons and other non-text (3). */
|
|
132
|
+
export const AA: number;
|
|
133
|
+
export const AA_NON_TEXT: number;
|
|
134
|
+
export function contrastPairs(hex: Record<string, string>, mode: Mode, options?: { strict?: boolean; fg?: string }): ContrastPair[];
|
|
135
|
+
/** Every pairing the build checks (strict by default), light then dark. `hex` is a theme's `resolved`. */
|
|
136
|
+
export function contrastReport(hex: Partial<Record<Mode, Record<string, string>>>, options?: { strict?: boolean }): ContrastRow[];
|
|
137
|
+
/** APCA Lc of `text` on `background` (APCA-W3 0.0.98G-4g). Advisory: it never fails the build. */
|
|
138
|
+
export function apcaContrast(text: string, background: string): number;
|
|
139
|
+
export function simulateCvd(hex: string, type: CvdType | 'none' | null | undefined): string;
|
|
140
|
+
export function pairDistances(hex: Record<string, string>, type: CvdType | 'none', pairs?: [string, string][]): PairDistance[];
|
|
141
|
+
export function cvdWarnings(resolved: Partial<Record<Mode, Record<string, string>>>, options?: { pairs?: [string, string][]; threshold?: number }): CvdWarning[];
|
|
142
|
+
export function describeCvdWarnings(warnings: CvdWarning[]): string[];
|
|
143
|
+
|
|
144
|
+
/** A decoded shadcn/ui preset code (ui.shadcn.com/create). */
|
|
145
|
+
export type ShadcnPreset = {
|
|
146
|
+
version: 'a' | 'b';
|
|
147
|
+
style: string;
|
|
148
|
+
baseColor: string;
|
|
149
|
+
theme: string;
|
|
150
|
+
chartColor: string;
|
|
151
|
+
iconLibrary: string;
|
|
152
|
+
font: string;
|
|
153
|
+
fontHeading: string;
|
|
154
|
+
radius: string;
|
|
155
|
+
menuAccent: string;
|
|
156
|
+
menuColor: string;
|
|
157
|
+
};
|
|
158
|
+
/** A shadcn registry item (registry:theme or registry:font), as JSON. */
|
|
159
|
+
export type ShadcnRegistryItem = {
|
|
160
|
+
$schema: string;
|
|
161
|
+
name: string;
|
|
162
|
+
title?: string;
|
|
163
|
+
description?: string;
|
|
164
|
+
type: 'registry:theme' | 'registry:font';
|
|
165
|
+
registryDependencies?: string[];
|
|
166
|
+
cssVars?: { theme?: Record<string, string>; light?: Record<string, string>; dark?: Record<string, string> };
|
|
167
|
+
font?: { family: string; provider: 'google'; import: string; variable: string; subsets?: string[]; weight?: string[]; dependency?: string };
|
|
168
|
+
};
|
|
169
|
+
export function isShadcnPresetCode(s: unknown): s is string;
|
|
170
|
+
export function decodeShadcnPreset(code: string): ShadcnPreset | null;
|
|
171
|
+
export function encodeShadcnPreset(preset: Partial<ShadcnPreset>): string;
|
|
172
|
+
/** The nearest Halftone recipe for a shadcn preset, and a note for each value it approximated or dropped. */
|
|
173
|
+
export function recipeFromShadcn(preset: ShadcnPreset): { recipe: Recipe; notes: string[] };
|
|
174
|
+
export const SHADCN_FONTS_URL: string;
|
|
175
|
+
export function shadcnTheme(theme: Theme, options?: { name?: string; title?: string; description?: string; fontsUrl?: string }): ShadcnRegistryItem;
|
|
176
|
+
export function shadcnFont(id: string, role?: 'body' | 'heading' | 'mono'): ShadcnRegistryItem | null;
|
|
177
|
+
export function shadcnFontPath(id: string, role: 'body' | 'heading' | 'mono'): string;
|
package/lib/theme/index.mjs
CHANGED
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { generateTheme } from './generate.mjs';
|
|
7
7
|
export { encodeRecipe, decodeRecipe, isThemeCode } from './codec.mjs';
|
|
8
|
-
export { DEFAULT_RECIPE, defaultTypeset, FLOW, LEADING, OPTIONS, normalizeRecipe, sameRecipe, RECIPE_VERSION, TYPESET_KEYS } from './recipe.mjs';
|
|
8
|
+
export { DEFAULT_RECIPE, defaultStyles, defaultTypeset, FLOW, LEADING, OPTIONS, normalizeRecipe, sameRecipe, RECIPE_VERSION, STYLE_KEYS, TYPESET_KEYS } from './recipe.mjs';
|
|
9
9
|
export { FONTS, fontById, googleFontsHref } from './fonts.mjs';
|
|
10
10
|
export { PRESETS, presetById } from './presets.mjs';
|
|
11
|
-
export { themeVars, themeCss, themeHex, typeVars } from './vars.mjs';
|
|
11
|
+
export { styleVars, themeVars, themeCss, themeHex, typeVars } from './vars.mjs';
|
|
12
12
|
export { applyRecipe, themeStatus, THEME_EXTENSION } from './apply.mjs';
|
|
13
13
|
export { BRAND_RAMPS } from './palettes.mjs';
|
|
14
|
+
export {
|
|
15
|
+
decodeShadcnPreset,
|
|
16
|
+
encodeShadcnPreset,
|
|
17
|
+
isShadcnPresetCode,
|
|
18
|
+
recipeFromShadcn,
|
|
19
|
+
SHADCN_FONTS_URL,
|
|
20
|
+
shadcnFont,
|
|
21
|
+
shadcnFontPath,
|
|
22
|
+
shadcnTheme,
|
|
23
|
+
} from './shadcn.mjs';
|
|
24
|
+
export { CVD_TYPES, cvdWarnings, describeCvdWarnings, DISTINCT_PAIRS, MIN_DELTA_E, pairDistances, simulateCvd } from '../cvd.mjs';
|
|
14
25
|
|
|
26
|
+
export { AA, AA_NON_TEXT, contrastPairs, contrastReport } from '../contrast.mjs';
|
|
27
|
+
export { apcaContrast } from '../apca.mjs';
|
package/lib/theme/presets.mjs
CHANGED
|
@@ -12,20 +12,20 @@ export const PRESETS = [
|
|
|
12
12
|
{
|
|
13
13
|
id: 'editorial',
|
|
14
14
|
name: 'Editorial',
|
|
15
|
-
description: 'Serif headlines, rust ink, hairlines and almost no rounding.',
|
|
16
|
-
recipe: { brand: '#9a3412', neutral: 'stone', radius: 'sm', controls: 'match', font: { heading: 'fraunces', body: 'inter', mono: 'ibm-plex-mono' }, stroke: 'thin', depth: 'flat', density: 'comfortable', border: 'hairline', size: '18', leading: 'relaxed', flow: 'normal', measure: '70' },
|
|
15
|
+
description: 'Serif headlines, rust ink, hairlines, underlined fields and almost no rounding.',
|
|
16
|
+
recipe: { brand: '#9a3412', neutral: 'stone', radius: 'sm', controls: 'match', font: { heading: 'fraunces', body: 'inter', mono: 'ibm-plex-mono' }, stroke: 'thin', depth: 'flat', density: 'comfortable', border: 'hairline', size: '18', leading: 'relaxed', flow: 'normal', measure: '70', button: 'solid', field: 'underline' },
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
id: 'soft',
|
|
20
20
|
name: 'Soft',
|
|
21
|
-
description: 'Lavender, generous radius,
|
|
22
|
-
recipe: { brand: '#8b5cf6', neutral: 'brand', radius: '2xl', controls: 'pill', font: { heading: 'plus-jakarta-sans', body: 'plus-jakarta-sans', mono: 'geist-mono' }, stroke: 'regular', depth: 'soft', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '60' },
|
|
21
|
+
description: 'Lavender, generous radius, tinted buttons, filled fields and room to breathe.',
|
|
22
|
+
recipe: { brand: '#8b5cf6', neutral: 'brand', radius: '2xl', controls: 'pill', font: { heading: 'plus-jakarta-sans', body: 'plus-jakarta-sans', mono: 'geist-mono' }, stroke: 'regular', depth: 'soft', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '60', button: 'soft', field: 'filled' },
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
id: 'playful',
|
|
26
26
|
name: 'Playful',
|
|
27
|
-
description: 'Pink, chunky icons, round display type and
|
|
28
|
-
recipe: { brand: '#db2777', neutral: 'neutral', radius: 'xl', controls: 'pill', font: { heading: 'bricolage-grotesque', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'relaxed', flow: 'normal', measure: '60' },
|
|
27
|
+
description: 'Pink, chunky icons, round display type, lifted cards and raised buttons.',
|
|
28
|
+
recipe: { brand: '#db2777', neutral: 'neutral', radius: 'xl', controls: 'pill', font: { heading: 'bricolage-grotesque', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'relaxed', flow: 'normal', measure: '60', button: 'raised', field: 'outline' },
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
id: 'brutalist',
|
|
@@ -51,6 +51,18 @@ export const PRESETS = [
|
|
|
51
51
|
description: 'Teal on warm greys, a book serif for headings, thin lines, no shadows.',
|
|
52
52
|
recipe: { brand: '#0d9488', neutral: 'stone', radius: 'xl', controls: 'match', font: { heading: 'lora', body: 'manrope', mono: 'source-code-pro' }, stroke: 'thin', depth: 'flat', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '70' },
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
id: 'tactile',
|
|
56
|
+
name: 'Tactile',
|
|
57
|
+
description: 'Warm orange, raised buttons you want to press, filled fields, rounded and friendly.',
|
|
58
|
+
recipe: { brand: '#ea580c', neutral: 'stone', radius: 'xl', controls: 'match', font: { heading: 'nunito', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'normal', flow: 'normal', measure: '70', button: 'raised', field: 'filled' },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'studio',
|
|
62
|
+
name: 'Studio',
|
|
63
|
+
description: 'Indigo on slate, tinted buttons, underlined fields, hairlines: quiet and precise.',
|
|
64
|
+
recipe: { brand: '#4f46e5', neutral: 'slate', radius: 'md', controls: 'match', font: { heading: 'geist', body: 'geist', mono: 'geist-mono' }, stroke: 'regular', depth: 'flat', density: 'comfortable', border: 'hairline', size: '15', leading: 'normal', flow: 'normal', measure: '70', button: 'soft', field: 'underline' },
|
|
65
|
+
},
|
|
54
66
|
];
|
|
55
67
|
|
|
56
68
|
export const presetById = (id) => PRESETS.find((p) => p.id === id);
|
package/lib/theme/recipe.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The typeset (font.mono, size, leading, flow, measure) came later. A recipe without it gets the defaults,
|
|
8
8
|
* which are what the kits always had, and a theme with the default typeset still encodes as a ht1 code.
|
|
9
|
+
* So did the styles (button, field): their defaults are the kits' original look, and a theme that keeps them
|
|
10
|
+
* encodes as ht1 or ht2 as before.
|
|
9
11
|
*/
|
|
10
12
|
import { FONTS } from './fonts.mjs';
|
|
11
13
|
|
|
@@ -34,16 +36,46 @@ export const OPTIONS = {
|
|
|
34
36
|
flow: ['normal', 'tight', 'loose'],
|
|
35
37
|
/** Typeset: the longest line of long-form text (Prose), in characters. */
|
|
36
38
|
measure: ['70', '60', '80', '90'],
|
|
39
|
+
/** Style: the primary button. Solid fill, a brand tint, a brand outline, or solid with a darker bottom edge. */
|
|
40
|
+
button: ['solid', 'soft', 'outline', 'raised'],
|
|
41
|
+
/** Style: text fields (inputs, text areas, select triggers). A box, a tinted field with a line, or a line. */
|
|
42
|
+
field: ['outline', 'filled', 'underline'],
|
|
37
43
|
};
|
|
38
44
|
|
|
39
45
|
/** The recipe keys that make up the typeset, besides font.mono. */
|
|
40
46
|
export const TYPESET_KEYS = ['size', 'leading', 'flow', 'measure'];
|
|
47
|
+
/** The recipe keys that are component styles (ht3 codes). */
|
|
48
|
+
export const STYLE_KEYS = ['button', 'field'];
|
|
41
49
|
|
|
42
50
|
/** Base radius per option: a primitive radius step (a reference) or 0. */
|
|
43
51
|
export const RADIUS = { none: 0, sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', '2xl': '2xl' };
|
|
44
52
|
export const STROKE = { thin: 1.5, regular: 2, bold: 2.5 };
|
|
45
53
|
/** px; hairline renders as one device pixel on a 2x screen. */
|
|
46
54
|
export const BORDER = { hairline: 0.5, regular: 1, heavy: 2 };
|
|
55
|
+
/**
|
|
56
|
+
* Button styles as numbers (px), on top of the colours in semantic.mjs (styleColors). `alpha` is the fill's
|
|
57
|
+
* opacity: an outline button has no fill. `border` is every side, `edge` the bottom (raised: a thicker,
|
|
58
|
+
* darker bottom edge).
|
|
59
|
+
*/
|
|
60
|
+
export const BUTTON = {
|
|
61
|
+
solid: { alpha: 1, border: 0, edge: 0 },
|
|
62
|
+
soft: { alpha: 1, border: 0, edge: 0 },
|
|
63
|
+
outline: { alpha: 0, border: 2, edge: 2 },
|
|
64
|
+
raised: { alpha: 1, border: 0, edge: 3 },
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Field styles. `alpha` per mode (outline: transparent, with a faint tint in dark, as the kits always had).
|
|
68
|
+
* `border` / `edge`: 'border' = the theme's border width, 'line' = at least 1px (it's the field's only edge).
|
|
69
|
+
* `round` / `roundBottom`: 1 = the md radius, 0 = square. `inset`: 1 = the usual inner padding, 0 = flush.
|
|
70
|
+
*/
|
|
71
|
+
export const FIELD = {
|
|
72
|
+
outline: { alpha: { light: 0, dark: 0.3 }, border: 'border', edge: 'border', round: 1, roundBottom: 1, inset: 1 },
|
|
73
|
+
filled: { alpha: { light: 1, dark: 1 }, border: 0, edge: 'line', round: 1, roundBottom: 0, inset: 1 },
|
|
74
|
+
underline: { alpha: { light: 0, dark: 0 }, border: 0, edge: 'line', round: 0, roundBottom: 0, inset: 0 },
|
|
75
|
+
};
|
|
76
|
+
/** A field width in px for a border width. */
|
|
77
|
+
export const fieldWidth = (value, borderWidth) => (value === 'border' ? borderWidth : value === 'line' ? Math.max(1, borderWidth) : value);
|
|
78
|
+
|
|
47
79
|
/** Line height of running text (× size). 1.75 = leading-7 at 16px, what the kits' paragraphs always had. */
|
|
48
80
|
export const LEADING = { tight: 1.6, normal: 1.75, relaxed: 1.9 };
|
|
49
81
|
/** Space between blocks of long-form text, in em. */
|
|
@@ -136,6 +168,8 @@ export const DEFAULT_RECIPE = Object.freeze({
|
|
|
136
168
|
leading: 'normal',
|
|
137
169
|
flow: 'normal',
|
|
138
170
|
measure: '70',
|
|
171
|
+
button: 'solid',
|
|
172
|
+
field: 'outline',
|
|
139
173
|
});
|
|
140
174
|
|
|
141
175
|
const HEX = /^#[0-9a-f]{6}$/;
|
|
@@ -143,6 +177,9 @@ const HEX = /^#[0-9a-f]{6}$/;
|
|
|
143
177
|
/** Does the recipe use the default typeset (what every ht1 code means)? */
|
|
144
178
|
export const defaultTypeset = (r) => r.font.mono === DEFAULT_RECIPE.font.mono && TYPESET_KEYS.every((k) => r[k] === DEFAULT_RECIPE[k]);
|
|
145
179
|
|
|
180
|
+
/** Does the recipe keep the default styles (solid buttons, outline fields: every ht1 and ht2 code)? */
|
|
181
|
+
export const defaultStyles = (r) => STYLE_KEYS.every((k) => r[k] === DEFAULT_RECIPE[k]);
|
|
182
|
+
|
|
146
183
|
/** Fill in defaults, normalise, and throw on anything invalid. Returns a new, plain recipe. */
|
|
147
184
|
export function normalizeRecipe(input = {}) {
|
|
148
185
|
const r = { ...DEFAULT_RECIPE, ...input, font: { ...DEFAULT_RECIPE.font, ...input.font } };
|
|
@@ -175,6 +212,8 @@ export function normalizeRecipe(input = {}) {
|
|
|
175
212
|
leading: r.leading,
|
|
176
213
|
flow: r.flow,
|
|
177
214
|
measure: r.measure,
|
|
215
|
+
button: r.button,
|
|
216
|
+
field: r.field,
|
|
178
217
|
};
|
|
179
218
|
if (input.preset) out.preset = String(input.preset);
|
|
180
219
|
return out;
|
package/lib/theme/semantic.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* references per mode. With a curated seed this is exactly the kit's original map; a generated seed
|
|
4
4
|
* moves the brand steps to wherever the seed landed (its anchor).
|
|
5
5
|
*/
|
|
6
|
+
import { STEPS } from './palettes.mjs';
|
|
7
|
+
|
|
6
8
|
const n = (step) => `{primitive.color.neutral.${step}}`;
|
|
7
9
|
const b = (step) => `{primitive.color.brand.${step}}`;
|
|
8
10
|
const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
@@ -57,6 +59,29 @@ export function semanticColors(anchor = '600') {
|
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
const shift = (step, by) => STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The colours a recipe's styles add (button, field), from the base colours after their contrast fix, so a moved
|
|
66
|
+
* primary moves the button with it. Field edges that are the field's only boundary (filled, underline) are a
|
|
67
|
+
* mid neutral, which the gate holds to 3:1 (contrast.mjs).
|
|
68
|
+
*/
|
|
69
|
+
export function styleColors({ button, field }, anchor, colors) {
|
|
70
|
+
const dark = darkStep(anchor);
|
|
71
|
+
const buttons = {
|
|
72
|
+
solid: { button: colors.primary, 'button-foreground': colors['primary-foreground'], 'button-edge': colors.primary },
|
|
73
|
+
soft: { button: { light: b(100), dark: b(900) }, 'button-foreground': { light: b(700), dark: b(200) }, 'button-edge': { light: b(100), dark: b(900) } },
|
|
74
|
+
outline: { button: colors.background, 'button-foreground': colors.primary, 'button-edge': colors.primary },
|
|
75
|
+
raised: { button: colors.primary, 'button-foreground': colors['primary-foreground'], 'button-edge': { light: b(shift(anchor, 2)), dark: b(shift(dark, 2)) } },
|
|
76
|
+
};
|
|
77
|
+
const fields = {
|
|
78
|
+
outline: { field: colors.input, 'field-edge': colors.input },
|
|
79
|
+
filled: { field: colors.muted, 'field-edge': { light: n(500), dark: n(400) } },
|
|
80
|
+
underline: { field: colors.background, 'field-edge': { light: n(500), dark: n(500) } },
|
|
81
|
+
};
|
|
82
|
+
return { ...buttons[button], ...fields[field] };
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
/** Tokens that share the brand step; when the fixer moves `primary`, these move with it. */
|
|
61
86
|
export const BRAND_LINKED = ['ring', 'sidebar-primary', 'sidebar-ring'];
|
|
62
87
|
|