@halftone-ds/tokens 1.1.0 → 1.3.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.
@@ -8,9 +8,11 @@
8
8
  * typeset: false leave the text sizes alone (the docs site keeps its reading sizes)
9
9
  */
10
10
  import { hexToOklch } from '../lib/color.mjs';
11
- import { group, radiusCalc } from '../lib/resolve.mjs';
11
+ import { group, radiusCss } from '../lib/resolve.mjs';
12
12
  import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
13
- import { typeVars } from '../lib/theme/vars.mjs';
13
+ import { COMPONENT_NAMES, COMPONENTS, componentColors, componentVars } from '../lib/theme/components.mjs';
14
+ import { hasExtras } from '../lib/theme/extras.mjs';
15
+ import { extraVars, styleVars, textStyleVars, typeVars } from '../lib/theme/vars.mjs';
14
16
 
15
17
  const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
16
18
  const SYSTEM_SANS = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
@@ -27,8 +29,96 @@ function textScale(ctx) {
27
29
  .join('\n');
28
30
  }
29
31
 
32
+ /** A font role's family, as @theme's --font-* resolve it (app/fonts.ts or a live theme sets --kit-font-*). */
33
+ const FONT_ROLE = {
34
+ heading: `var(--kit-font-heading, var(--kit-font-body, ${SYSTEM_SANS}))`,
35
+ body: `var(--kit-font-body, ${SYSTEM_SANS})`,
36
+ mono: `var(--kit-font-mono, ${SYSTEM_MONO})`,
37
+ };
38
+
39
+ /**
40
+ * The text styles as utilities: type-h1 … type-blockquote set the style's font, size, line height, tracking and
41
+ * weight from its variables. leading-*, tracking-* and font-* on the same element still win, as on text-*.
42
+ */
43
+ function textStyleUtilities(names) {
44
+ return names
45
+ .map(
46
+ (name) => `@utility type-${name} {
47
+ font-family: var(--type-${name}-font);
48
+ font-size: var(--type-${name}-size);
49
+ line-height: var(--tw-leading, var(--type-${name}-leading));
50
+ letter-spacing: var(--tw-tracking, var(--type-${name}-tracking));
51
+ font-weight: var(--tw-font-weight, var(--type-${name}-weight));
52
+ }`
53
+ )
54
+ .join('\n');
55
+ }
56
+
57
+ /**
58
+ * A system's extras (lib/theme/extras.mjs): their variables in :root and .dark, colours and corners in @theme (so
59
+ * bg-<name>, text-<name>, rounded-<name> exist), and shadow-, border- and type- utilities. No kit component uses them.
60
+ */
61
+ function extrasCss(ctx) {
62
+ const list = ctx.extras;
63
+ if (!list || !hasExtras(list)) return { light: '', dark: '', theme: '', utilities: '' };
64
+ const vars = extraVars(list, 'oklch', (role) => FONT_ROLE[role]);
65
+ const lines = (map) => Object.entries(map).map(([k, v]) => ` ${k}: ${v};`).join('\n');
66
+ // Corners are literal in @theme (rounded-<name>), not variables of their own.
67
+ const light = Object.fromEntries(Object.entries(vars.light).filter(([k]) => !k.startsWith('--radius-')));
68
+ const theme = [
69
+ ...list.color.map((c) => ` --color-${c.name}: var(--${c.name});`),
70
+ ...list.radius.map((r) => ` --radius-${r.name}: ${rem(r.px)};`),
71
+ ];
72
+ const utilities = [
73
+ ...list.shadow.map((s) => `@utility shadow-${s.name} {\n box-shadow: var(--shadow-${s.name});\n}`),
74
+ ...list.border.map((b) => `@utility border-${b.name} {\n border-width: var(--border-${b.name}-width);\n border-style: ${b.style ?? 'solid'};\n}`),
75
+ textStyleUtilities(list.text.map((t) => t.name)),
76
+ ].filter(Boolean);
77
+ return {
78
+ light: `\n /* Extras: this system's own tokens. No kit component uses them; screens may. */\n${lines(light)}`,
79
+ dark: Object.keys(vars.dark).length ? `\n /* Extras */\n${lines(vars.dark)}` : '',
80
+ theme: theme.length ? `\n /* Extras */\n${theme.join('\n')}` : '',
81
+ utilities: utilities.length ? `\n/* Extras */\n${utilities.join('\n')}\n` : '',
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The component layer (Button, Chip, Badge, Input): the variables per mode, each variant's colours as Tailwind colours
87
+ * (bg-button-primary-bg; a colour with an opacity mixes it in), rounded-button / -chip / -badge, and type-button,
88
+ * type-chip, type-badge and type-field for the text. Heights and padding are read as h-(--button-h-md), px-(--button-x).
89
+ */
90
+ function componentsCss(ctx) {
91
+ const vars = componentVars(ctx.components, 'oklch', hexToOklch, (role) => FONT_ROLE[role]);
92
+ const lines = (map) => Object.entries(map).map(([k, v]) => ` ${k}: ${v};`).join('\n');
93
+ const colors = componentColors(ctx.components, (key, alpha) => (alpha ? `color-mix(in oklab, var(--${key}) calc(var(--${key}-a) * 100%), transparent)` : `var(--${key})`));
94
+ const theme = [
95
+ ...COMPONENT_NAMES.filter((n) => n !== 'input').map((n) => ` --radius-${n}: var(--${n}-radius);`),
96
+ ...Object.entries(colors).map(([key, css]) => ` --color-${key}: ${css};`),
97
+ ];
98
+ const utilities = COMPONENT_NAMES.map((n) => COMPONENTS[n].prefix).map(
99
+ (p) => `@utility type-${p} {
100
+ font-family: var(--${p}-text-font);
101
+ font-size: var(--${p}-text-size);
102
+ line-height: var(--tw-leading, var(--${p}-text-leading));
103
+ letter-spacing: var(--tw-tracking, var(--${p}-text-tracking));
104
+ }`
105
+ );
106
+ return {
107
+ light: `\n /* Components: Button, Chip, Badge, Input (corners, sizes, text, each variant's colours). */\n${lines(vars.light)}`,
108
+ dark: `\n /* Components */\n${lines(vars.dark)}`,
109
+ theme: `\n /* Components: rounded-button, bg-button-primary-bg, border-field-focus-border… */\n${theme.join('\n')}`,
110
+ utilities: `${utilities.join('\n')}\n`,
111
+ };
112
+ }
113
+
30
114
  export default function shadcnWeb(ctx, options) {
115
+ const extras = extrasCss(ctx);
116
+ const components = componentsCss(ctx);
31
117
  const vars = (mode) => ctx.colors.map((c) => ` --${c.name}: ${hexToOklch(c[mode])};`).join('\n');
118
+ const styles = (mode) => Object.entries(styleVars(ctx.shape, 'oklch')[mode]).map(([k, v]) => ` ${k}: ${v};`).join('\n');
119
+ // The styles' fills carry their own opacity (an outline button, a transparent field).
120
+ const color = (name) =>
121
+ name === 'button' || name === 'field' ? `color-mix(in oklab, var(--${name}) calc(var(--${name}-alpha) * 100%), transparent)` : `var(--${name})`;
32
122
  const shadows = (mode) => Object.entries(ctx.shape.shadows[mode]).map(([level, css]) => ` --shadow-${level}: ${css};`).join('\n');
33
123
  const contents = `/* ${ctx.header} */
34
124
  @custom-variant dark (&:is(.dark *));
@@ -40,18 +130,26 @@ export default function shadcnWeb(ctx, options) {
40
130
  --icon-stroke: ${ctx.shape.stroke};${options.density === false ? '' : `\n --density: ${ctx.shape.density.scale};`}${
41
131
  options.typeset === false ? '' : '\n' + Object.entries(typeVars(ctx.type)).map(([k, v]) => ` ${k}: ${v};`).join('\n')
42
132
  }
133
+ ${styles('light')}
43
134
  ${shadows('light')}
44
- ${vars('light')}
135
+ /* The text styles (type-h1…type-blockquote), literal at the typeset: an edited style is one variable. */
136
+ ${Object.entries(textStyleVars(ctx.textStyles, 'oklch', (role) => FONT_ROLE[role])).map(([k, v]) => ` ${k}: ${v};`).join('\n')}
137
+ ${vars('light')}${components.light}${extras.light}
45
138
  }
46
139
 
47
140
  .dark {
141
+ ${styles('dark')}
48
142
  ${shadows('dark')}
49
- ${vars('dark')}
143
+ ${vars('dark')}${components.dark}${extras.dark}
50
144
  }
51
145
 
52
146
  @theme inline {
53
- ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCalc(s.scale)};`).join('\n')}
147
+ ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCss(s)};`).join('\n')}
54
148
  --radius-control: var(--radius-control);
149
+ /* The field style: rounded-field, rounded-b-field-b, px-field-x. Widths are border-(length:--field-border-w) etc. */
150
+ --radius-field: var(--field-radius);
151
+ --radius-field-b: var(--field-radius-b);
152
+ --spacing-field-x: var(--field-x);
55
153
  --default-border-width: var(--border-width);${options.density === false ? '' : `\n /* The theme's density scales the whole spacing scale (p-4, gap-2, h-9…). */\n --spacing: calc(0.25rem * var(--density));`}
56
154
  --shadow-2xs: var(--shadow-1);
57
155
  --shadow-xs: var(--shadow-1);
@@ -65,9 +163,11 @@ ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCalc(s.scale)};`).j
65
163
  --font-mono: var(--kit-font-mono, ${SYSTEM_MONO});${
66
164
  options.typeset === false ? '' : `\n /* The theme's typeset scales every text size and its line height (text-sm, text-base…). */\n${textScale(ctx)}`
67
165
  }
68
- ${ctx.colors.map((c) => ` --color-${c.name}: var(--${c.name});`).join('\n')}
166
+ ${ctx.colors.map((c) => ` --color-${c.name}: ${color(c.name)};`).join('\n')}${components.theme}${extras.theme}
69
167
  }
70
168
 
169
+ ${textStyleUtilities(Object.keys(ctx.textStyles))}
170
+ ${components.utilities}${extras.utilities}
71
171
  @layer base {
72
172
  * {
73
173
  @apply border-border outline-ring/50;
@@ -4,6 +4,7 @@
4
4
  * options: { out: 'lib/theme.ts', navTheme?: 'expo-router/react-navigation' | false }
5
5
  */
6
6
  import { camel, group } from '../lib/resolve.mjs';
7
+ import { COMPONENT_NAMES, componentVarNames, TOUCH_TARGET } from '../lib/theme/components.mjs';
7
8
  import { presetById, sameRecipe, themeStatus } from '../lib/theme/index.mjs';
8
9
 
9
10
  /** The applied theme, for a read-only summary in the app (Foundations). */
@@ -15,6 +16,25 @@ function themeInfo(source) {
15
16
  return JSON.stringify({ code: status.code, name, edited: status.drift.length > 0, recipe: status.recipe }, null, 2);
16
17
  }
17
18
 
19
+ const round = (n) => Number(n.toFixed(4));
20
+
21
+ /** The component layer's numbers: sizes (px, for the phone touch-target floor) and each text's font role and tracking. */
22
+ function componentsObject(components) {
23
+ return COMPONENT_NAMES.map((name) => {
24
+ const c = components[name];
25
+ const text = { font: c.text.font, size: round(c.text.size), lineHeight: round(c.text.leading), tracking: c.text.tracking };
26
+ // The font role is typed as a role, so code can branch on it (the object is `as const`).
27
+ const json = JSON.stringify({ height: c.h, x: c.x, radius: c.radius, text }).replace(/"font":("\w+")/, `"font":$1 as 'heading' | 'body' | 'mono'`);
28
+ return ` ${name}: ${json},`;
29
+ }).join('\n');
30
+ }
31
+
32
+ function textStylesObject(styles) {
33
+ return Object.entries(styles)
34
+ .map(([name, s]) => ` ${name}: ${JSON.stringify({ font: s.font, weight: s.weight, size: round(s.size), lineHeight: round(s.lineHeight), tracking: s.tracking })},`)
35
+ .join('\n');
36
+ }
37
+
18
38
  function colorObject(colors, mode) {
19
39
  return colors.map((c) => ` ${camel(c.name)}: '${c[mode]}',`).join('\n');
20
40
  }
@@ -72,6 +92,17 @@ ${primitiveGroup(ctx.prim, ['radius'])}
72
92
  iconStroke: ${ctx.shape.stroke},
73
93
  /** Width of \`border\` in px. */
74
94
  borderWidth: ${ctx.shape.borderWidth},
95
+ /** The theme's styles (button: solid · soft · outline · raised; field: outline · filled · underline) and their
96
+ * numbers (px): bg-button / bg-field, border-button-w, rounded-field… already follow them. */
97
+ styles: ${JSON.stringify(ctx.styles)},
98
+ button: ${JSON.stringify(ctx.shape.button)},
99
+ field: ${JSON.stringify(ctx.shape.field)},
100
+ /** The component layer (Button, Chip, Badge, Input) as numbers: heights and padding (px), corner, and the text's
101
+ * font role and tracking. The classes (h-button-md, rounded-button, text-type-button…) already follow them; this is
102
+ * for the touch-target floor (phones raise a height under ${TOUCH_TARGET} with hitSlop) and the font. */
103
+ components: {
104
+ ${componentsObject(ctx.components)}
105
+ },
75
106
  /** Control heights and padding (px) for the theme's density: h-control-sm, h-control, h-control-lg, px-control-x. */
76
107
  density: ${JSON.stringify({ sm: ctx.shape.density.sm, md: ctx.shape.density.md, lg: ctx.shape.density.lg, x: ctx.shape.density.x })},
77
108
  /** The type scale at 16px; the theme's typeset multiplies it (text-* classes are already scaled). */
@@ -81,6 +112,12 @@ ${primitiveGroup(ctx.prim, ['font', 'size'])}
81
112
  /** The typeset: body size (px) and its scale, running-text leading and its factor on the text sizes' line
82
113
  * heights, and Prose's flow (em between blocks) and measure (characters per line). */
83
114
  typeset: ${JSON.stringify(ctx.type)},
115
+ /** The Text variants at the typeset, a system's edited styles included: font role, weight, size and line height
116
+ * (px), tracking (em). The classes (text-type-h1, leading-type-h1…) already follow them; this is for code
117
+ * that picks something per style, such as a font file per weight. */
118
+ textStyles: {
119
+ ${textStylesObject(ctx.textStyles)}
120
+ },
84
121
  fontFamily: {
85
122
  ${primitiveGroup(ctx.prim, ['font', 'family'])}
86
123
  },
@@ -92,6 +129,9 @@ ${primitiveGroup(ctx.prim, ['easing'])}
92
129
  },
93
130
  } as const;
94
131
 
132
+ /** The component layer's CSS variables (what an embedded kit accepts from the docs site's live theme). */
133
+ export const COMPONENT_VARS = ${JSON.stringify(componentVarNames())} as const;
134
+
95
135
  /** The theme tokens.json was generated from (\`halftone-tokens theme apply\`), or null. \`edited\`: theme tokens
96
136
  * were changed by hand since. */
97
137
  export const THEME_INFO: ThemeInfo | null = ${themeInfo(ctx.source)};
@@ -115,6 +155,8 @@ export type ThemeInfo = {
115
155
  leading: string;
116
156
  flow: string;
117
157
  measure: string;
158
+ button?: string;
159
+ field?: string;
118
160
  preset?: string;
119
161
  };
120
162
  };