@halftone-ds/tokens 1.2.0 → 1.3.1
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 +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +187 -28
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +110 -22
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +40 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halftone-ds/tokens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Design tokens (W3C DTCG) to NativeWind, Tailwind 4 / shadcn, TypeScript, DESIGN.md and Figma, with a WCAG AA contrast gate. The token build behind Halftone.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"types": "./lib/theme/index.d.mts",
|
|
29
29
|
"default": "./lib/theme/index.mjs"
|
|
30
30
|
},
|
|
31
|
+
"./system": {
|
|
32
|
+
"types": "./lib/system/index.d.mts",
|
|
33
|
+
"default": "./lib/system/index.mjs"
|
|
34
|
+
},
|
|
31
35
|
"./lib/*": "./lib/*.mjs",
|
|
32
36
|
"./targets/*": "./targets/*.mjs",
|
|
33
37
|
"./package.json": "./package.json"
|
package/targets/design-md.mjs
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* short, platform-neutral prose an agent can load on its own. The spec has no light/dark modes, so
|
|
4
4
|
* `colors` carries the light value under each name and the dark one under `<name>-dark`.
|
|
5
5
|
* The detailed rules stay in DESIGN_SYSTEM.md; this file points there instead of repeating them.
|
|
6
|
-
* options: { out: 'DESIGN.md', name?: string, description?: string }
|
|
6
|
+
* options: { out: 'DESIGN.md', name?: string, description?: string, platform?: 'mobile' | 'web' }
|
|
7
7
|
*/
|
|
8
8
|
import { describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
|
|
9
|
+
import { describeComponents } from '../lib/theme/components.mjs';
|
|
10
|
+
import { describeExtras } from '../lib/theme/extras.mjs';
|
|
9
11
|
import { themeFonts } from './fonts.mjs';
|
|
10
12
|
import { themeStatus } from '../lib/theme/index.mjs';
|
|
11
13
|
import { group } from '../lib/resolve.mjs';
|
|
@@ -79,8 +81,9 @@ const STYLE_TEXT = {
|
|
|
79
81
|
|
|
80
82
|
export default function designMd(ctx, options) {
|
|
81
83
|
const name = options.name ?? 'Halftone';
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
+
// The component layer: one theme for both kits (Button, Chip, Badge, Input).
|
|
85
|
+
const { button, input } = ctx.components;
|
|
86
|
+
const corner = (r) => (r >= 9999 ? '"{rounded.full}"' : px(r));
|
|
84
87
|
const description =
|
|
85
88
|
options.description ?? 'Brand-agnostic prototype kit: shadcn semantics, light and dark, WCAG AA enforced at build time.';
|
|
86
89
|
const colors = ctx.colors
|
|
@@ -88,7 +91,7 @@ export default function designMd(ctx, options) {
|
|
|
88
91
|
.join('\n');
|
|
89
92
|
// The theme's radius: rounded-* are multiples of its base (rounded-lg); control is buttons and chips.
|
|
90
93
|
const rounded = [
|
|
91
|
-
...ctx.radius.steps.map((s) => ` ${s.name}: ${px(
|
|
94
|
+
...ctx.radius.steps.map((s) => ` ${s.name}: ${px(s.px)}`),
|
|
92
95
|
` control: ${px(Math.min(ctx.shape.control, 9999))}`,
|
|
93
96
|
` full: ${px(9999)}`,
|
|
94
97
|
].join('\n');
|
|
@@ -103,6 +106,19 @@ export default function designMd(ctx, options) {
|
|
|
103
106
|
const radius = Object.fromEntries(group(ctx.prim, ['radius']).map((t) => [t.key, t.value]));
|
|
104
107
|
const duration = Object.fromEntries(group(ctx.prim, ['duration']).map((t) => [t.key, t.value]));
|
|
105
108
|
|
|
109
|
+
// A system's own tokens: listed, never in the front matter's shared vocabulary.
|
|
110
|
+
const extras = describeExtras(ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] }, { platform: options.platform ?? 'mobile' });
|
|
111
|
+
const extrasSection = extras.length
|
|
112
|
+
? `
|
|
113
|
+
## Extras (this brand's own)
|
|
114
|
+
|
|
115
|
+
Tokens this system added beyond the kit's slots. No kit component uses them; use them in screens, as classes like
|
|
116
|
+
any token (never their hex). They are not part of the shared vocabulary above.
|
|
117
|
+
|
|
118
|
+
${extras.join('\n')}
|
|
119
|
+
`
|
|
120
|
+
: '';
|
|
121
|
+
|
|
106
122
|
const contents = `---
|
|
107
123
|
version: alpha
|
|
108
124
|
name: ${q(name)}
|
|
@@ -119,13 +135,13 @@ components:
|
|
|
119
135
|
button-primary:
|
|
120
136
|
backgroundColor: "{colors.primary}"
|
|
121
137
|
textColor: "{colors.primary-foreground}"
|
|
122
|
-
rounded:
|
|
123
|
-
height:
|
|
138
|
+
rounded: ${corner(button.radius)}
|
|
139
|
+
height: ${px(button.h.md)}
|
|
124
140
|
button-secondary:
|
|
125
141
|
backgroundColor: "{colors.secondary}"
|
|
126
142
|
textColor: "{colors.secondary-foreground}"
|
|
127
|
-
rounded:
|
|
128
|
-
height:
|
|
143
|
+
rounded: ${corner(button.radius)}
|
|
144
|
+
height: ${px(button.h.md)}
|
|
129
145
|
card:
|
|
130
146
|
backgroundColor: "{colors.card}"
|
|
131
147
|
textColor: "{colors.card-foreground}"
|
|
@@ -134,8 +150,8 @@ components:
|
|
|
134
150
|
input:
|
|
135
151
|
backgroundColor: "{colors.background}"
|
|
136
152
|
textColor: "{colors.foreground}"
|
|
137
|
-
rounded:
|
|
138
|
-
height:
|
|
153
|
+
rounded: ${corner(input.radius)}
|
|
154
|
+
height: ${px(input.h.md)}
|
|
139
155
|
---
|
|
140
156
|
|
|
141
157
|
<!-- ${ctx.header} -->
|
|
@@ -147,7 +163,7 @@ components:
|
|
|
147
163
|
A neutral, brand-agnostic base for clickable prototypes on phone and web. Calm surfaces, one brand
|
|
148
164
|
colour reserved for the primary action and focus, generous spacing, and both light and dark themes
|
|
149
165
|
from day one. ${status.applied ? `The applied theme is \`${status.code}\`${status.drift.length ? ' (edited by hand since)' : ''}. ` : ''}Re-branding means applying a
|
|
150
|
-
theme (\`npx halftone-tokens theme apply <code>\`,
|
|
166
|
+
theme (\`npx halftone-tokens theme apply <code|file.halftone.json>\`, both from Studio on the docs site) or editing
|
|
151
167
|
\`tokens/tokens.json\`; everything else follows. The full rules and the component registry are in
|
|
152
168
|
\`DESIGN_SYSTEM.md\` — read it before building UI.
|
|
153
169
|
|
|
@@ -171,7 +187,7 @@ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
|
|
|
171
187
|
|
|
172
188
|
On a stronger tint, the text stays \`text-foreground\` and the tint, an icon or a dot carries the
|
|
173
189
|
colour. \`halftone-tokens check\` flags class strings that break this.
|
|
174
|
-
|
|
190
|
+
${extrasSection}
|
|
175
191
|
## Typography
|
|
176
192
|
|
|
177
193
|
Headings (h1–h4, titles) use ${fontName(fonts.heading)}; everything else uses ${fontName(fonts.body)}; code and
|
|
@@ -195,13 +211,14 @@ a floating button). Content — inputs included — stays flat.
|
|
|
195
211
|
|
|
196
212
|
## Shapes
|
|
197
213
|
|
|
198
|
-
Buttons are pills. Inputs ${radius
|
|
214
|
+
Buttons ${button.radius >= 9999 ? 'are pills' : `have ${button.radius}px corners`}. Inputs ${input.radius}px, cards ${radius.lg}px, larger surfaces ${radius.xl}px, sheet tops
|
|
199
215
|
${radius['2xl']}px; chips, avatars and dots are fully round.
|
|
200
216
|
|
|
201
217
|
## Components
|
|
202
218
|
|
|
203
|
-
Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist.
|
|
204
|
-
|
|
219
|
+
Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist. ${STYLE_TEXT.button[ctx.styles.button]} ${STYLE_TEXT.field[ctx.styles.field]}
|
|
220
|
+
Button, Chip, Badge and Input draw the theme's component tokens, the same in both kits: ${describeComponents(ctx.components).join('; ')}.
|
|
221
|
+
Never restyle them by hand. Phones raise any tap target under 44px to 44.
|
|
205
222
|
Motion: ${duration.fast}ms for micro-feedback, ${duration.base}ms for most transitions, ${duration.slow}ms for sheets, and
|
|
206
223
|
nothing moves when the OS asks for reduced motion.
|
|
207
224
|
|
package/targets/figma.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export default function figma(ctx, options) {
|
|
|
16
16
|
.map((t) => [t.key, { value: t.value, code: code(t.key) }])
|
|
17
17
|
);
|
|
18
18
|
// The theme's radius: rounded-* scale with its base (rounded-lg); rounded-control is buttons and chips.
|
|
19
|
-
const radius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, { value:
|
|
19
|
+
const radius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, { value: s.px, code: `rounded-${s.name}` }]));
|
|
20
20
|
radius.control = { value: Math.min(ctx.shape.control, 9999), code: 'rounded-control' };
|
|
21
21
|
radius.full = { value: 9999, code: 'rounded-full' };
|
|
22
22
|
const numbers = {
|
package/targets/fonts.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* --kit-font-body and --kit-font-mono (app/tokens.css maps them to font-heading / font-sans / font-mono)
|
|
7
7
|
* An empty family (heading, body) or Menlo (mono) is the platform's own font: nothing to load.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
9
|
+
* A curated font (lib/theme/fonts.mjs) loads the weights it lists; any other Google Fonts family (a system's
|
|
10
|
+
* fonts) loads 400–700 under the packages' own naming (fontByFamily). halftone-tokens theme apply installs the
|
|
11
|
+
* @expo-google-fonts packages they need (see expoPackages).
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
+
import { fontByFamily } from '../lib/theme/fonts.mjs';
|
|
13
14
|
import { group } from '../lib/resolve.mjs';
|
|
14
15
|
|
|
15
16
|
const SUFFIX = { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' };
|
|
@@ -20,8 +21,7 @@ export function themeFonts(ctx) {
|
|
|
20
21
|
const pick = (role) => {
|
|
21
22
|
const family = families[role];
|
|
22
23
|
if (!family) return null;
|
|
23
|
-
const font =
|
|
24
|
-
if (!font) throw new Error(`fonts: "${family}" (font.family.${role}) is not in the curated font list; pick one of ${FONTS.filter((f) => !f.system).map((f) => f.family).join(', ')}`);
|
|
24
|
+
const font = fontByFamily(family);
|
|
25
25
|
return font.system ? null : font;
|
|
26
26
|
};
|
|
27
27
|
return { heading: pick('heading'), body: pick('body'), mono: pick('mono') };
|
package/targets/llms.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The tokens section of llms.txt: a compact Markdown fragment that registry-build stitches into the
|
|
3
3
|
* app's llms.txt (and the docs site into /llms.txt).
|
|
4
|
-
* options: { out: 'tokens/generated/llms-tokens.md' }
|
|
4
|
+
* options: { out: 'tokens/generated/llms-tokens.md', platform?: 'mobile' | 'web' }
|
|
5
5
|
*/
|
|
6
6
|
import { group } from '../lib/resolve.mjs';
|
|
7
7
|
import { AA, describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
|
|
8
8
|
import { themeStatus } from '../lib/theme/index.mjs';
|
|
9
9
|
import { themeFonts } from './fonts.mjs';
|
|
10
|
+
import { describeComponents } from '../lib/theme/components.mjs';
|
|
11
|
+
import { describeExtras } from '../lib/theme/extras.mjs';
|
|
10
12
|
|
|
11
13
|
export default function llms(ctx, options) {
|
|
12
14
|
const rows = ctx.colors.map((c) => `| \`${c.name}\` | ${c.light} | ${c.dark} |`).join('\n');
|
|
@@ -16,6 +18,7 @@ export default function llms(ctx, options) {
|
|
|
16
18
|
.join(' · ');
|
|
17
19
|
const fonts = themeFonts(ctx);
|
|
18
20
|
const theme = themeStatus(ctx.source);
|
|
21
|
+
const extras = describeExtras(ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] }, { platform: options.platform ?? 'mobile' });
|
|
19
22
|
const contents = `<!-- ${ctx.header} -->
|
|
20
23
|
## Tokens
|
|
21
24
|
|
|
@@ -31,14 +34,15 @@ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
|
|
|
31
34
|
${rows}
|
|
32
35
|
|
|
33
36
|
- Spacing (4-pt grid, Tailwind numeric scale): ${steps(['space'])}
|
|
34
|
-
- Radius (\`rounded-*\`): ${ctx.radius.steps.map((s) => `${s.name} ${
|
|
37
|
+
- Radius (\`rounded-*\`): ${ctx.radius.steps.map((s) => `${s.name} ${s.px}px`).join(' · ')} (multiples of \`--radius\` = ${ctx.radius.base}px); \`rounded-control\` ${ctx.shape.control >= 9999 ? 'pill' : `${ctx.shape.control}px`}
|
|
35
38
|
- Depth: \`shadow-sm\` (controls, cards), \`shadow-md\` (raised, popovers), \`shadow-lg\` (menus, dialogs) are the theme's three elevation levels; no shadow colour modifiers on surfaces (thumbs and floating elements may add \`shadow-black/20\`)
|
|
36
39
|
- Border \`border\` ${ctx.shape.borderWidth}px · icon stroke ${ctx.shape.stroke} (an Icon's \`strokeWidth\` is relative to 2)
|
|
37
|
-
- Styles: ${ctx.styles.button} buttons, ${ctx.styles.field} fields
|
|
40
|
+
- Styles: ${ctx.styles.button} buttons, ${ctx.styles.field} fields
|
|
41
|
+
- Components (one theme for both kits; never restyle them by hand): ${describeComponents(ctx.components).join('; ')}. Phones raise any tap target under 44px to 44
|
|
38
42
|
- Fonts: headings ${fonts.heading?.family ?? 'system'}, body ${fonts.body?.family ?? 'system'}, mono ${fonts.mono?.family ?? 'system'} (applied by Text; \`font-heading\` makes any Text a heading, \`font-mono\` code and figures)
|
|
39
43
|
- Typeset: body ${ctx.type.size}px, leading ${ctx.type.leading}; every \`text-*\` size below scales by ${ctx.type.scale}${theme.applied ? `\n- Theme: \`${theme.code}\` (\`npx halftone-tokens theme show\`)` : ''}
|
|
40
44
|
- Font sizes (\`text-*\`): ${steps(['font', 'size'])}
|
|
41
45
|
- Durations: ${steps(['duration'], 'ms')}
|
|
42
|
-
`;
|
|
46
|
+
${extras.length ? `\n### Extras (this brand's own)\n\nNo kit component uses these; screens may, as classes (never their hex):\n\n${extras.join('\n')}\n` : ''}`;
|
|
43
47
|
return [{ path: options.out, contents }];
|
|
44
48
|
}
|
package/targets/nativewind3.mjs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* options: { css: 'global.css', tailwind: 'tokens/generated/tailwind.theme.js' }
|
|
5
5
|
*/
|
|
6
6
|
import { hexToHslTriplet } from '../lib/color.mjs';
|
|
7
|
-
import { cssName, group, isColor,
|
|
8
|
-
import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
|
|
9
|
-
import {
|
|
7
|
+
import { cssName, group, isColor, radiusCss } from '../lib/resolve.mjs';
|
|
8
|
+
import { shadowCss, TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
|
|
9
|
+
import { COMPONENT_NAMES, COMPONENTS, componentColors, componentVars } from '../lib/theme/components.mjs';
|
|
10
|
+
import { extraTextStyles } from '../lib/theme/extras.mjs';
|
|
11
|
+
import { extraVars, styleVars, textStyleVars } from '../lib/theme/vars.mjs';
|
|
10
12
|
|
|
11
13
|
const rem = (px) => (px >= 9999 ? '9999px' : `${Number((px / 16).toFixed(4))}rem`);
|
|
12
14
|
|
|
@@ -17,7 +19,8 @@ function cssBlock(selector, tokens, shape, mode, colors) {
|
|
|
17
19
|
const lines = tokens
|
|
18
20
|
// Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native. The styles'
|
|
19
21
|
// numbers come from shape (with their units), below.
|
|
20
|
-
|
|
22
|
+
// A system's radius steps are in borderRadius below, with their units.
|
|
23
|
+
.filter((t) => t.$type !== 'shadow' && !SHAPE.has(cssName(t)) && !/^(density|type|radius)-/.test(cssName(t)))
|
|
21
24
|
.filter((t) => isColor(t) || !/^(button|field)-/.test(cssName(t)))
|
|
22
25
|
.map((t) => {
|
|
23
26
|
const name = cssName(t);
|
|
@@ -29,10 +32,19 @@ function cssBlock(selector, tokens, shape, mode, colors) {
|
|
|
29
32
|
lines.push(` --radius-control: ${rem(shape.control)};`, ` --border-width: ${shape.borderWidth}px;`, ` --icon-stroke: ${shape.stroke};`);
|
|
30
33
|
for (const size of ['sm', 'md', 'lg', 'x']) lines.push(` --control-${size}: ${shape.density[size]}px;`);
|
|
31
34
|
}
|
|
35
|
+
// The text styles (Text h1…blockquote): literal at the typeset, so an edited style is one variable.
|
|
36
|
+
if (shape.textStyles) for (const [name, value] of Object.entries(textStyleVars(shape.textStyles, 'hsl'))) lines.push(` ${name}: ${value};`);
|
|
32
37
|
// Style colours a token file from before the styles doesn't have (resolve.mjs adds them), then the styles' numbers.
|
|
33
38
|
const present = new Set(tokens.filter(isColor).map(cssName));
|
|
34
39
|
for (const c of colors) if (!present.has(c.name)) lines.push(` --${c.name}: ${hexToHslTriplet(c[mode])};`);
|
|
35
40
|
for (const [name, value] of Object.entries(styleVars(shape.styleShape, 'hsl')[mode])) lines.push(` ${name}: ${value};`);
|
|
41
|
+
// The component layer (Button, Chip, Badge, Input): corners, sizes, text and each variant's colours.
|
|
42
|
+
if (shape.components) for (const [name, value] of Object.entries(componentVars(shape.components, 'hsl', hexToHslTriplet)[mode])) lines.push(` ${name}: ${value};`);
|
|
43
|
+
// A system's extras: colours, text styles and border widths (corners and shadows are literal in tailwind.theme.js).
|
|
44
|
+
if (shape.extras) {
|
|
45
|
+
const extra = Object.entries(extraVars(shape.extras, 'hsl')[mode]).filter(([name]) => !/^--(radius|shadow)-/.test(name));
|
|
46
|
+
if (extra.length) lines.push(' /* Extras: this system\'s own tokens. No kit component uses them; screens may. */', ...extra.map(([name, value]) => ` ${name}: ${value};`));
|
|
47
|
+
}
|
|
36
48
|
return ` ${selector} {\n${lines.join('\n')}\n }`;
|
|
37
49
|
}
|
|
38
50
|
|
|
@@ -43,16 +55,16 @@ export default function nativewind3(ctx, options) {
|
|
|
43
55
|
@tailwind utilities;
|
|
44
56
|
|
|
45
57
|
@layer base {
|
|
46
|
-
${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape }, 'light', ctx.colors)}
|
|
58
|
+
${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape, textStyles: ctx.textStyles, extras: ctx.extras, components: ctx.components }, 'light', ctx.colors)}
|
|
47
59
|
|
|
48
|
-
${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape }, 'dark', ctx.colors)}
|
|
60
|
+
${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape, extras: ctx.extras, components: ctx.components }, 'dark', ctx.colors)}
|
|
49
61
|
}
|
|
50
62
|
`;
|
|
51
63
|
const colors = Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`]));
|
|
52
64
|
// The styles' fills carry their own opacity (an outline button, a transparent field). No /90 modifiers on these.
|
|
53
65
|
colors.button = 'hsl(var(--button) / var(--button-alpha))';
|
|
54
66
|
colors.field = 'hsl(var(--field) / var(--field-alpha))';
|
|
55
|
-
const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name,
|
|
67
|
+
const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCss(s)]));
|
|
56
68
|
borderRadius.control = 'var(--radius-control)';
|
|
57
69
|
// Field style: rounded-field (top) and rounded-b-field-b (bottom).
|
|
58
70
|
borderRadius.field = 'var(--field-radius)';
|
|
@@ -77,7 +89,45 @@ ${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape }, 'dark', ctx.col
|
|
|
77
89
|
const lineHeight = { prose: rem(size * leading) };
|
|
78
90
|
const spacing = { 'control-x': 'var(--control-x)', 'field-x': 'var(--field-x)', flow: rem(size * flow) };
|
|
79
91
|
const maxWidth = { measure: rem(measure * size * 0.5) };
|
|
80
|
-
|
|
92
|
+
// The text styles, one key per property: text-type-h1, leading-type-h1, tracking-type-h1, font-type-h1 (Text's
|
|
93
|
+
// variants). Variables (global.css), so a system's edited style changes one value.
|
|
94
|
+
for (const name of Object.keys(ctx.textStyles)) {
|
|
95
|
+
fontSize[`type-${name}`] = `var(--type-${name}-size)`;
|
|
96
|
+
lineHeight[`type-${name}`] = `var(--type-${name}-leading)`;
|
|
97
|
+
}
|
|
98
|
+
const letterSpacing = Object.fromEntries(Object.keys(ctx.textStyles).map((name) => [`type-${name}`, `var(--type-${name}-tracking)`]));
|
|
99
|
+
const fontWeight = Object.fromEntries(Object.keys(ctx.textStyles).map((name) => [`type-${name}`, `var(--type-${name}-weight)`]));
|
|
100
|
+
// The component layer: bg-button-primary-bg, rounded-button, h-button-md, px-button-x, text-type-button,
|
|
101
|
+
// border-button-primary-w… Each colour carries its opacity (--button-primary-bg-a).
|
|
102
|
+
Object.assign(colors, componentColors(ctx.components, (key, alpha) => (alpha ? `hsl(var(--${key}) / var(--${key}-a))` : `hsl(var(--${key}))`)));
|
|
103
|
+
for (const name of COMPONENT_NAMES) {
|
|
104
|
+
const c = ctx.components[name];
|
|
105
|
+
const p = COMPONENTS[name].prefix;
|
|
106
|
+
if (name !== 'input') borderRadius[p] = `var(--${p}-radius)`;
|
|
107
|
+
for (const size of Object.keys(c.h)) height[`${p}-${size}`] = `var(--${p}-h-${size})`;
|
|
108
|
+
if (name !== 'input') spacing[`${p}-x`] = `var(--${p}-x)`;
|
|
109
|
+
fontSize[`type-${p}`] = `var(--${p}-text-size)`;
|
|
110
|
+
lineHeight[`type-${p}`] = `var(--${p}-text-leading)`;
|
|
111
|
+
letterSpacing[`type-${p}`] = `var(--${p}-text-tracking)`;
|
|
112
|
+
}
|
|
113
|
+
Object.assign(spacing, { 'button-x-sm': 'var(--button-x-sm)', 'button-x-lg': 'var(--button-x-lg)' });
|
|
114
|
+
for (const variant of Object.keys(ctx.components.button.variants)) {
|
|
115
|
+
borderWidth[`button-${variant}-w`] = `var(--button-${variant}-border-w)`;
|
|
116
|
+
borderWidth[`button-${variant}-edge-w`] = `var(--button-${variant}-edge-w)`;
|
|
117
|
+
}
|
|
118
|
+
// A system's extras: bg-<name>, rounded-<name>, shadow-<name>, border-<name> (its width), text-type-<name>…
|
|
119
|
+
const extras = ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] };
|
|
120
|
+
for (const c of extras.color) colors[c.name] = `hsl(var(--${c.name}))`;
|
|
121
|
+
for (const r of extras.radius) borderRadius[r.name] = rem(r.px);
|
|
122
|
+
for (const sh of extras.shadow) boxShadow[sh.name] = shadowCss(sh.light);
|
|
123
|
+
for (const b of extras.border) borderWidth[b.name] = `var(--border-${b.name}-width)`;
|
|
124
|
+
for (const name of Object.keys(extraTextStyles(extras))) {
|
|
125
|
+
fontSize[`type-${name}`] = `var(--type-${name}-size)`;
|
|
126
|
+
lineHeight[`type-${name}`] = `var(--type-${name}-leading)`;
|
|
127
|
+
letterSpacing[`type-${name}`] = `var(--type-${name}-tracking)`;
|
|
128
|
+
fontWeight[`type-${name}`] = `var(--type-${name}-weight)`;
|
|
129
|
+
}
|
|
130
|
+
const tailwind = `// ${ctx.header}\nmodule.exports = ${JSON.stringify({ colors, borderRadius, borderWidth, boxShadow, height, minHeight: height, width: height, spacing, fontSize, lineHeight, letterSpacing, fontWeight, maxWidth }, null, 2)};\n`;
|
|
81
131
|
return [
|
|
82
132
|
{ path: options.css, contents: css },
|
|
83
133
|
{ path: options.tailwind, contents: tailwind },
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* options: { out: 'tokens/generated/theme.registry.json' }
|
|
5
5
|
*/
|
|
6
6
|
import { hexToHslTriplet, hexToOklch } from '../lib/color.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { radiusCss } from '../lib/resolve.mjs';
|
|
8
8
|
|
|
9
9
|
export default function registryTheme(ctx, options) {
|
|
10
10
|
const radius = `${ctx.radius.base / 16}rem`;
|
|
@@ -20,7 +20,7 @@ export default function registryTheme(ctx, options) {
|
|
|
20
20
|
theme: {
|
|
21
21
|
extend: {
|
|
22
22
|
colors: Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`])),
|
|
23
|
-
borderRadius: Object.fromEntries(ctx.radius.steps.map((s) => [s.name,
|
|
23
|
+
borderRadius: Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCss(s)])),
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
26
|
},
|
|
@@ -28,7 +28,7 @@ export default function registryTheme(ctx, options) {
|
|
|
28
28
|
},
|
|
29
29
|
web: {
|
|
30
30
|
cssVars: {
|
|
31
|
-
theme: Object.fromEntries(ctx.radius.steps.map((s) => [`radius-${s.name}`,
|
|
31
|
+
theme: Object.fromEntries(ctx.radius.steps.map((s) => [`radius-${s.name}`, radiusCss(s)])),
|
|
32
32
|
light: { radius, ...vars(hexToOklch, 'light') },
|
|
33
33
|
dark: vars(hexToOklch, 'dark'),
|
|
34
34
|
},
|
package/targets/shadcn-web.mjs
CHANGED
|
@@ -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,
|
|
11
|
+
import { group, radiusCss } from '../lib/resolve.mjs';
|
|
12
12
|
import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
|
|
13
|
-
import {
|
|
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,7 +29,91 @@ 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');
|
|
32
118
|
const styles = (mode) => Object.entries(styleVars(ctx.shape, 'oklch')[mode]).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
33
119
|
// The styles' fills carry their own opacity (an outline button, a transparent field).
|
|
@@ -46,17 +132,19 @@ export default function shadcnWeb(ctx, options) {
|
|
|
46
132
|
}
|
|
47
133
|
${styles('light')}
|
|
48
134
|
${shadows('light')}
|
|
49
|
-
|
|
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}
|
|
50
138
|
}
|
|
51
139
|
|
|
52
140
|
.dark {
|
|
53
141
|
${styles('dark')}
|
|
54
142
|
${shadows('dark')}
|
|
55
|
-
${vars('dark')}
|
|
143
|
+
${vars('dark')}${components.dark}${extras.dark}
|
|
56
144
|
}
|
|
57
145
|
|
|
58
146
|
@theme inline {
|
|
59
|
-
${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${
|
|
147
|
+
${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCss(s)};`).join('\n')}
|
|
60
148
|
--radius-control: var(--radius-control);
|
|
61
149
|
/* The field style: rounded-field, rounded-b-field-b, px-field-x. Widths are border-(length:--field-border-w) etc. */
|
|
62
150
|
--radius-field: var(--field-radius);
|
|
@@ -75,9 +163,11 @@ ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCalc(s.scale)};`).j
|
|
|
75
163
|
--font-mono: var(--kit-font-mono, ${SYSTEM_MONO});${
|
|
76
164
|
options.typeset === false ? '' : `\n /* The theme's typeset scales every text size and its line height (text-sm, text-base…). */\n${textScale(ctx)}`
|
|
77
165
|
}
|
|
78
|
-
${ctx.colors.map((c) => ` --color-${c.name}: ${color(c.name)};`).join('\n')}
|
|
166
|
+
${ctx.colors.map((c) => ` --color-${c.name}: ${color(c.name)};`).join('\n')}${components.theme}${extras.theme}
|
|
79
167
|
}
|
|
80
168
|
|
|
169
|
+
${textStyleUtilities(Object.keys(ctx.textStyles))}
|
|
170
|
+
${components.utilities}${extras.utilities}
|
|
81
171
|
@layer base {
|
|
82
172
|
* {
|
|
83
173
|
@apply border-border outline-ring/50;
|
package/targets/ts-theme.mjs
CHANGED
|
@@ -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). */
|
|
@@ -11,10 +12,30 @@ function themeInfo(source) {
|
|
|
11
12
|
const status = themeStatus(source);
|
|
12
13
|
if (!status.applied) return 'null';
|
|
13
14
|
const preset = presetById(status.recipe.preset ?? '');
|
|
14
|
-
|
|
15
|
+
// A system is named after itself; its code is null when it has overrides a code can't carry.
|
|
16
|
+
const name = status.system?.name ?? (preset && sameRecipe(preset.recipe, status.recipe) ? preset.name : 'Custom');
|
|
15
17
|
return JSON.stringify({ code: status.code, name, edited: status.drift.length > 0, recipe: status.recipe }, null, 2);
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
const round = (n) => Number(n.toFixed(4));
|
|
21
|
+
|
|
22
|
+
/** The component layer's numbers: sizes (px, for the phone touch-target floor) and each text's font role and tracking. */
|
|
23
|
+
function componentsObject(components) {
|
|
24
|
+
return COMPONENT_NAMES.map((name) => {
|
|
25
|
+
const c = components[name];
|
|
26
|
+
const text = { font: c.text.font, size: round(c.text.size), lineHeight: round(c.text.leading), tracking: c.text.tracking };
|
|
27
|
+
// The font role is typed as a role, so code can branch on it (the object is `as const`).
|
|
28
|
+
const json = JSON.stringify({ height: c.h, x: c.x, radius: c.radius, text }).replace(/"font":("\w+")/, `"font":$1 as 'heading' | 'body' | 'mono'`);
|
|
29
|
+
return ` ${name}: ${json},`;
|
|
30
|
+
}).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textStylesObject(styles) {
|
|
34
|
+
return Object.entries(styles)
|
|
35
|
+
.map(([name, s]) => ` ${name}: ${JSON.stringify({ font: s.font, weight: s.weight, size: round(s.size), lineHeight: round(s.lineHeight), tracking: s.tracking })},`)
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
function colorObject(colors, mode) {
|
|
19
40
|
return colors.map((c) => ` ${camel(c.name)}: '${c[mode]}',`).join('\n');
|
|
20
41
|
}
|
|
@@ -77,6 +98,12 @@ ${primitiveGroup(ctx.prim, ['radius'])}
|
|
|
77
98
|
styles: ${JSON.stringify(ctx.styles)},
|
|
78
99
|
button: ${JSON.stringify(ctx.shape.button)},
|
|
79
100
|
field: ${JSON.stringify(ctx.shape.field)},
|
|
101
|
+
/** The component layer (Button, Chip, Badge, Input) as numbers: heights and padding (px), corner, and the text's
|
|
102
|
+
* font role and tracking. The classes (h-button-md, rounded-button, text-type-button…) already follow them; this is
|
|
103
|
+
* for the touch-target floor (phones raise a height under ${TOUCH_TARGET} with hitSlop) and the font. */
|
|
104
|
+
components: {
|
|
105
|
+
${componentsObject(ctx.components)}
|
|
106
|
+
},
|
|
80
107
|
/** Control heights and padding (px) for the theme's density: h-control-sm, h-control, h-control-lg, px-control-x. */
|
|
81
108
|
density: ${JSON.stringify({ sm: ctx.shape.density.sm, md: ctx.shape.density.md, lg: ctx.shape.density.lg, x: ctx.shape.density.x })},
|
|
82
109
|
/** The type scale at 16px; the theme's typeset multiplies it (text-* classes are already scaled). */
|
|
@@ -86,6 +113,12 @@ ${primitiveGroup(ctx.prim, ['font', 'size'])}
|
|
|
86
113
|
/** The typeset: body size (px) and its scale, running-text leading and its factor on the text sizes' line
|
|
87
114
|
* heights, and Prose's flow (em between blocks) and measure (characters per line). */
|
|
88
115
|
typeset: ${JSON.stringify(ctx.type)},
|
|
116
|
+
/** The Text variants at the typeset, a system's edited styles included: font role, weight, size and line height
|
|
117
|
+
* (px), tracking (em). The classes (text-type-h1, leading-type-h1…) already follow them; this is for code
|
|
118
|
+
* that picks something per style, such as a font file per weight. */
|
|
119
|
+
textStyles: {
|
|
120
|
+
${textStylesObject(ctx.textStyles)}
|
|
121
|
+
},
|
|
89
122
|
fontFamily: {
|
|
90
123
|
${primitiveGroup(ctx.prim, ['font', 'family'])}
|
|
91
124
|
},
|
|
@@ -97,12 +130,17 @@ ${primitiveGroup(ctx.prim, ['easing'])}
|
|
|
97
130
|
},
|
|
98
131
|
} as const;
|
|
99
132
|
|
|
133
|
+
/** The component layer's CSS variables (what an embedded kit accepts from the docs site's live theme). */
|
|
134
|
+
export const COMPONENT_VARS = ${JSON.stringify(componentVarNames())} as const;
|
|
135
|
+
|
|
100
136
|
/** The theme tokens.json was generated from (\`halftone-tokens theme apply\`), or null. \`edited\`: theme tokens
|
|
101
137
|
* were changed by hand since. */
|
|
102
138
|
export const THEME_INFO: ThemeInfo | null = ${themeInfo(ctx.source)};
|
|
103
139
|
|
|
104
140
|
export type ThemeInfo = {
|
|
105
|
-
code
|
|
141
|
+
/** null for a system (\`.halftone.json\`) with overrides no theme code carries. */
|
|
142
|
+
code: string | null;
|
|
143
|
+
/** The preset's name, the system's name, or 'Custom'. */
|
|
106
144
|
name: string;
|
|
107
145
|
edited: boolean;
|
|
108
146
|
recipe: {
|