@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.
@@ -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: any theme
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
- * checksum (payload mod 127) 7 7 a typo is caught instead of making another theme
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 ht1 whenever it can, so a theme that doesn't touch the typeset keeps the code it
18
- * always had (and older halftone-tokens can read it). The preset name is not in the code: a code is the theme,
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[12]-?/, '').replace(/[IL]/g, '1').replace(/O/g, '0').replace(/-/g, '');
81
- if (!/^HT[12]/.test(clean) && /^HT\d/.test(clean)) {
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 ht2- and 16)`);
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());
@@ -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' },
@@ -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
- let colors = semanticColors(anchorStep(recipe.brand));
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: BORDER[recipe.border],
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
  };
@@ -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;
@@ -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';
@@ -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, brand-tinted greys 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' },
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 lifted cards.',
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);
@@ -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;
@@ -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