orbiq-neural-ui-kit 0.0.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 +64 -0
- package/fesm2022/orbiq-neural-ui-kit.mjs +13521 -0
- package/fesm2022/orbiq-neural-ui-kit.mjs.map +1 -0
- package/package.json +45 -0
- package/src/theme.css +6 -0
- package/tailwind/colorPalette.js +193 -0
- package/tailwind/colors.json +1173 -0
- package/tailwind/content.js +58 -0
- package/tailwind/figma-tokens-to-theme.js +471 -0
- package/tailwind/figma-tokens-to-theme.test.js +22 -0
- package/tailwind/generated/colors.json +1173 -0
- package/tailwind/generated/effects.json +39 -0
- package/tailwind/generated/radius.json +14 -0
- package/tailwind/generated/typography.json +402 -0
- package/tailwind/iconPackPlugin.js +108 -0
- package/tailwind/index.js +2 -0
- package/tailwind/lucideIconsPlugin.js +23 -0
- package/tailwind/migrate-tokens-v2.js +938 -0
- package/tailwind/migrate-tokens-v2.test.js +528 -0
- package/tailwind/plugin.js +693 -0
- package/tailwind/preset.js +36 -0
- package/types/orbiq-neural-ui-kit.d.ts +1892 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
// Tailwind v3 does not merge `content` from a preset (see preset.js) — every
|
|
5
|
+
// consuming app must list frappe-ui's source globs in its own `content`.
|
|
6
|
+
// Resolve them relative to this file so they work whether frappe-ui sits in
|
|
7
|
+
// node_modules, a monorepo symlink, or a local workspace checkout.
|
|
8
|
+
const packageRoot = path.resolve(
|
|
9
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
'..',
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Tailwind's scanner (fast-glob/micromatch) reads backslashes as escape
|
|
14
|
+
// characters, not path separators — path.join emits backslashes on Windows,
|
|
15
|
+
// which would silently break every glob below. Force forward slashes.
|
|
16
|
+
function glob(pattern) {
|
|
17
|
+
return path.join(packageRoot, pattern).split(path.sep).join('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mirror this repo's own tailwind.config.js content globs for the supported
|
|
21
|
+
// surfaces (`./src/**`, `./icons/**`) — for those, two lists that are
|
|
22
|
+
// supposed to agree must not be able to drift apart. The repo config
|
|
23
|
+
// also globs all of `./experimental/**`; this list does not (see below). Broad
|
|
24
|
+
// `src/**` also covers files like src/utils/dialog.ts, which builds markup
|
|
25
|
+
// with `h('div', { class: '...' })` outside any component tree.
|
|
26
|
+
//
|
|
27
|
+
// `experimental/**` is deliberately excluded: frappe-ui/experimental carries
|
|
28
|
+
// no P14 promise and first-party consumers are told not to depend on it, so
|
|
29
|
+
// it isn't part of the supported public content contract this list covers.
|
|
30
|
+
// The explicit exceptions are migration parking spots for previously
|
|
31
|
+
// supported surfaces, covered until they are removed:
|
|
32
|
+
// - `experimental/SpriteIcons` (moved out of `frappe-ui/icons` in #904;
|
|
33
|
+
// IconPicker emits classes)
|
|
34
|
+
// - `experimental/TextEditor` (the v0 editor family, a supported root
|
|
35
|
+
// surface until #974, parked in #1007 while apps migrate to
|
|
36
|
+
// `frappe-ui/editor`)
|
|
37
|
+
// - `experimental/Calendar` (a supported root surface until #1020, parked
|
|
38
|
+
// with its API unchanged until a redesigned calendar family replaces it)
|
|
39
|
+
// - `experimental/Charts` (the v1 chart family, a supported root surface
|
|
40
|
+
// until #942, parked while apps migrate to `frappe-ui/charts`)
|
|
41
|
+
// - `experimental/CommandPalette` (a supported root surface until its removal
|
|
42
|
+
// in `1.0.0`; the seven-part family here is where its consumers port to)
|
|
43
|
+
/**
|
|
44
|
+
* Source globs that emit Tailwind classes in frappe-ui. Spread into your
|
|
45
|
+
* app's `tailwind.config.js` `content` array:
|
|
46
|
+
*
|
|
47
|
+
* import { content } from 'frappe-ui/tailwind'
|
|
48
|
+
* export default { content: [...content, './src/**\/*.vue'] }
|
|
49
|
+
*/
|
|
50
|
+
export const content = [
|
|
51
|
+
glob('src/**/*.{vue,js,ts,jsx,tsx}'),
|
|
52
|
+
glob('icons/**/*.{vue,js,ts,jsx,tsx}'),
|
|
53
|
+
glob('experimental/SpriteIcons/**/*.{vue,js,ts,jsx,tsx}'),
|
|
54
|
+
glob('experimental/TextEditor/**/*.{vue,js,ts,jsx,tsx}'),
|
|
55
|
+
glob('experimental/Calendar/**/*.{vue,js,ts,jsx,tsx}'),
|
|
56
|
+
glob('experimental/Charts/**/*.{vue,js,ts,jsx,tsx}'),
|
|
57
|
+
glob('experimental/CommandPalette/**/*.{vue,js,ts,jsx,tsx}'),
|
|
58
|
+
]
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator: reads the W3C Design Tokens Community Group JSON exported from
|
|
3
|
+
* Figma (espresso-v2-design-tokens/) and emits theme JSON files that the
|
|
4
|
+
* tailwind plugin can consume.
|
|
5
|
+
*
|
|
6
|
+
* Inputs: espresso-v2-design-tokens/*.tokens.json
|
|
7
|
+
* Outputs: tailwind/generated/{colors,radius,typography}.json
|
|
8
|
+
*
|
|
9
|
+
* Run with: yarn sync-tokens
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs'
|
|
13
|
+
import path from 'path'
|
|
14
|
+
import { fileURLToPath } from 'url'
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const REPO_ROOT = path.resolve(__dirname, '..')
|
|
18
|
+
const TOKENS_DIR = path.join(REPO_ROOT, 'espresso-v2-design-tokens')
|
|
19
|
+
const OUT_DIR = path.join(__dirname, 'generated')
|
|
20
|
+
|
|
21
|
+
// Color families mirrored from Figma's "🔵 Colour primitives" collection.
|
|
22
|
+
// Each appears under `light.<family>` and `dark.<family>` plus their alpha pair.
|
|
23
|
+
const COLOR_FAMILIES = [
|
|
24
|
+
'gray',
|
|
25
|
+
'blue',
|
|
26
|
+
'green',
|
|
27
|
+
'red',
|
|
28
|
+
'orange',
|
|
29
|
+
'amber',
|
|
30
|
+
'yellow',
|
|
31
|
+
'teal',
|
|
32
|
+
'cyan',
|
|
33
|
+
'purple',
|
|
34
|
+
'pink',
|
|
35
|
+
'violet',
|
|
36
|
+
]
|
|
37
|
+
// 'red-alpha' was listed here historically but Figma's primitives export has
|
|
38
|
+
// never contained a `light.red-alpha` / `dark.red-alpha` family — the guard
|
|
39
|
+
// below silently skips it, so it never emitted a token. Not listed, so the
|
|
40
|
+
// dead branch isn't there to skip going forward (#940).
|
|
41
|
+
const ALPHA_FAMILIES = ['gray-alpha']
|
|
42
|
+
const SEMANTIC_CATEGORIES = ['surface', 'surface-alpha', 'ink', 'outline', 'outline-alpha']
|
|
43
|
+
|
|
44
|
+
// Named aliases layered on top of Figma's numeric radius keys.
|
|
45
|
+
// Matched by px value, so the alias stays correct if Figma shifts.
|
|
46
|
+
// Only `none` survives — the deprecated size aliases (`sm`, `DEFAULT`, `md`,
|
|
47
|
+
// `lg`, `xl`, `2xl`) were removed in 1.0.0 per ADR-0006 (#998). Migrate old
|
|
48
|
+
// code with tailwind/migrate-tokens-v2.js.
|
|
49
|
+
const RADIUS_NAME_BY_PX = {
|
|
50
|
+
'0px': 'none',
|
|
51
|
+
}
|
|
52
|
+
// Preserved from current plugin.js — Figma doesn't model `full`.
|
|
53
|
+
const RADIUS_EXTRA = { full: '9999px' }
|
|
54
|
+
|
|
55
|
+
// Real Figma variable-font weights. Only Regular is customized (420); the rest
|
|
56
|
+
// are standard. NOTE: do NOT source these from the `text.styles` export — its
|
|
57
|
+
// fontWeight column is corrupt (Regular exports as 100/400 because the body
|
|
58
|
+
// styles use Inter's "Thin" named instance with a wght-axis override to 420
|
|
59
|
+
// that the exporter discards; Black exports as 700 instead of 800).
|
|
60
|
+
const FONT_WEIGHT_MAP = {
|
|
61
|
+
regular: 420,
|
|
62
|
+
medium: 500,
|
|
63
|
+
semibold: 600,
|
|
64
|
+
bold: 700,
|
|
65
|
+
extrabold: 800,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- HEX → OKLCH ----------
|
|
69
|
+
|
|
70
|
+
// Figma exports every color as hex (8-digit when it carries alpha), but
|
|
71
|
+
// colors.json ships oklch (fa18b8ade). Convert at generation time so a
|
|
72
|
+
// routine `yarn sync-tokens` can't revert the palette to hex (#986).
|
|
73
|
+
// Math from Björn Ottosson's OKLab reference implementation.
|
|
74
|
+
|
|
75
|
+
const fmt = (n) => String(Math.round(n * 1000) / 1000)
|
|
76
|
+
|
|
77
|
+
export function hexToOklch(hex) {
|
|
78
|
+
const value = hex.slice(1)
|
|
79
|
+
if (value.length !== 6 && value.length !== 8) {
|
|
80
|
+
throw new Error(`hexToOklch expects #rrggbb or #rrggbbaa, got "${hex}"`)
|
|
81
|
+
}
|
|
82
|
+
const int = (i) => parseInt(value.slice(i, i + 2), 16)
|
|
83
|
+
const [r, g, b] = [int(0), int(2), int(4)]
|
|
84
|
+
const alpha = value.length === 8 ? int(6) / 255 : 1
|
|
85
|
+
|
|
86
|
+
const lin = (c) => {
|
|
87
|
+
const v = c / 255
|
|
88
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
89
|
+
}
|
|
90
|
+
const [lr, lg, lb] = [lin(r), lin(g), lin(b)]
|
|
91
|
+
|
|
92
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
|
|
93
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
|
|
94
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
|
|
95
|
+
|
|
96
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
|
|
97
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
|
|
98
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
99
|
+
|
|
100
|
+
const C = Math.sqrt(A * A + B * B)
|
|
101
|
+
let H = (Math.atan2(B, A) * 180) / Math.PI
|
|
102
|
+
if (H < 0) H += 360
|
|
103
|
+
// Achromatic: a hue on a zero-chroma color is noise.
|
|
104
|
+
if (Math.round(C * 1000) === 0) H = 0
|
|
105
|
+
|
|
106
|
+
const lch = `${fmt(L)} ${fmt(C)} ${fmt(H)}`
|
|
107
|
+
return alpha < 1 ? `oklch(${lch} / ${fmt(alpha)})` : `oklch(${lch})`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Literal color values pass through here; alias references don't.
|
|
111
|
+
export function toOklch(value) {
|
|
112
|
+
return typeof value === 'string' && value.startsWith('#') ? hexToOklch(value) : value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readTokens(filename) {
|
|
116
|
+
return JSON.parse(fs.readFileSync(path.join(TOKENS_DIR, filename), 'utf8'))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ensureOutDir() {
|
|
120
|
+
fs.mkdirSync(OUT_DIR, { recursive: true })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeJSON(filename, data) {
|
|
124
|
+
const filepath = path.join(OUT_DIR, filename)
|
|
125
|
+
fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n')
|
|
126
|
+
console.log(` wrote ${path.relative(REPO_ROOT, filepath)}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- COLORS ----------
|
|
130
|
+
|
|
131
|
+
// Build colors.json in the shape colorPalette.js already consumes:
|
|
132
|
+
// { lightMode, darkMode, overlay, neutral, themedVariables: { light, dark } }
|
|
133
|
+
function buildColors() {
|
|
134
|
+
const primitives = readTokens('Colour primitives.Light.tokens.json')
|
|
135
|
+
const stylesLight = readTokens('Styles.Light.tokens.json')
|
|
136
|
+
const stylesDark = readTokens('Styles.Dark.tokens.json')
|
|
137
|
+
|
|
138
|
+
const colors = {
|
|
139
|
+
lightMode: {},
|
|
140
|
+
darkMode: {},
|
|
141
|
+
overlay: { white: {}, black: {} },
|
|
142
|
+
neutral: {
|
|
143
|
+
white: toOklch(primitives.neutral.white.$value),
|
|
144
|
+
black: toOklch(primitives.neutral.black.$value),
|
|
145
|
+
...(primitives.neutral.transparent
|
|
146
|
+
? { transparent: toOklch(primitives.neutral.transparent.$value) }
|
|
147
|
+
: {}),
|
|
148
|
+
},
|
|
149
|
+
themedVariables: {
|
|
150
|
+
light: Object.fromEntries(SEMANTIC_CATEGORIES.map((category) => [category, {}])),
|
|
151
|
+
dark: Object.fromEntries(SEMANTIC_CATEGORIES.map((category) => [category, {}])),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Primitive ramps — light.<family>.<shade>
|
|
156
|
+
for (const family of [...COLOR_FAMILIES, ...ALPHA_FAMILIES]) {
|
|
157
|
+
if (primitives.light?.[family]) {
|
|
158
|
+
colors.lightMode[family] = mapShades(primitives.light[family])
|
|
159
|
+
}
|
|
160
|
+
if (primitives.dark?.[family]) {
|
|
161
|
+
colors.darkMode[family] = mapShades(primitives.dark[family])
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Overlay ramps — white-alpha / black-alpha at top level of primitives.
|
|
166
|
+
if (primitives['white-alpha']) {
|
|
167
|
+
colors.overlay.white = mapShades(primitives['white-alpha'])
|
|
168
|
+
}
|
|
169
|
+
if (primitives['black-alpha']) {
|
|
170
|
+
colors.overlay.black = mapShades(primitives['black-alpha'])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Semantic aliases — Styles.Light/Dark → themedVariables.{light,dark}
|
|
174
|
+
for (const category of SEMANTIC_CATEGORIES) {
|
|
175
|
+
collectSemanticCategory(stylesLight, category, colors.themedVariables.light)
|
|
176
|
+
collectSemanticCategory(stylesDark, category, colors.themedVariables.dark)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return colors
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No legacy aliases: retired names (surface-white, surface-modal,
|
|
183
|
+
// outline-gray-modals, …) are intentionally NOT emitted so straggler usage
|
|
184
|
+
// fails visibly instead of silently keeping old styles alive. Migrate old
|
|
185
|
+
// code with tailwind/migrate-tokens-v2.js.
|
|
186
|
+
|
|
187
|
+
function mapShades(family) {
|
|
188
|
+
const out = {}
|
|
189
|
+
for (const [shade, token] of Object.entries(family)) {
|
|
190
|
+
if (token && token.$value) {
|
|
191
|
+
out[shade] = toOklch(token.$value)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Semantic names present in Figma's Styles export with zero call sites
|
|
198
|
+
// anywhere (#940 rule-5 census: frappe-ui's own source/docs/stories, all 9
|
|
199
|
+
// consumer apps, and frappe's ui/ package):
|
|
200
|
+
// - `alert-button-{default,info,success,warning,error}` (surface + ink):
|
|
201
|
+
// Alert's actual buttons color via the shared variant+theme axes (P4),
|
|
202
|
+
// not a per-alert-type token — this Figma spec never got wired to code.
|
|
203
|
+
// - `gray-2-overlay` (surface-alpha): resolves to the black/white overlay
|
|
204
|
+
// ramp rather than the gray-alpha ramp its name implies, breaking the
|
|
205
|
+
// `{family}-{step}` pattern every other surface-alpha entry follows, and
|
|
206
|
+
// nothing ever reached for it under either reading.
|
|
207
|
+
const DROPPED_SEMANTIC_NAMES = {
|
|
208
|
+
surface: [
|
|
209
|
+
'alert-button-default',
|
|
210
|
+
'alert-button-info',
|
|
211
|
+
'alert-button-success',
|
|
212
|
+
'alert-button-warning',
|
|
213
|
+
'alert-button-error',
|
|
214
|
+
],
|
|
215
|
+
'surface-alpha': ['gray-2-overlay'],
|
|
216
|
+
ink: [
|
|
217
|
+
'alert-button-default',
|
|
218
|
+
'alert-button-info',
|
|
219
|
+
'alert-button-success',
|
|
220
|
+
'alert-button-warning',
|
|
221
|
+
'alert-button-error',
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function collectSemanticCategory(styles, category, target) {
|
|
226
|
+
const section = styles[category]
|
|
227
|
+
if (!section) return
|
|
228
|
+
target[category] = target[category] || {}
|
|
229
|
+
const dropped = DROPPED_SEMANTIC_NAMES[category] || []
|
|
230
|
+
for (const [name, token] of Object.entries(section)) {
|
|
231
|
+
if (!token?.$value) continue
|
|
232
|
+
if (dropped.includes(name)) continue
|
|
233
|
+
// Resolve `{path.to.token}` aliases into the "lightMode/family/shade" format
|
|
234
|
+
// that colorPalette.js#resolveColorReference understands.
|
|
235
|
+
target[category][name] = aliasToReference(token.$value)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Convert a DTCG alias string like "{light.gray.50}" into the reference shape
|
|
240
|
+
// stored in colors.json today: "lightMode/gray/50". Non-aliases (literal hex)
|
|
241
|
+
// convert to oklch like every other resolved value.
|
|
242
|
+
function aliasToReference(value) {
|
|
243
|
+
if (typeof value !== 'string') return value
|
|
244
|
+
const match = value.match(/^\{(.+)\}$/)
|
|
245
|
+
if (!match) return toOklch(value)
|
|
246
|
+
const segments = match[1].split('.')
|
|
247
|
+
|
|
248
|
+
// {neutral.white} | {neutral.black}
|
|
249
|
+
if (segments[0] === 'neutral' && segments.length === 2) {
|
|
250
|
+
return `neutral/${segments[1]}`
|
|
251
|
+
}
|
|
252
|
+
// {white-alpha.50} | {black-alpha.50} → overlay/white/50 | overlay/black/50
|
|
253
|
+
if (segments[0] === 'white-alpha' || segments[0] === 'black-alpha') {
|
|
254
|
+
const color = segments[0].split('-')[0]
|
|
255
|
+
return `overlay/${color}/${segments[1]}`
|
|
256
|
+
}
|
|
257
|
+
// {light.gray.50} → lightMode/gray/50
|
|
258
|
+
// {light.gray-alpha.50} → lightMode/gray-alpha/50
|
|
259
|
+
if (segments[0] === 'light') {
|
|
260
|
+
return `lightMode/${segments.slice(1, -1).join('-')}/${segments[segments.length - 1]}`
|
|
261
|
+
}
|
|
262
|
+
if (segments[0] === 'dark') {
|
|
263
|
+
return `darkMode/${segments.slice(1, -1).join('-')}/${segments[segments.length - 1]}`
|
|
264
|
+
}
|
|
265
|
+
console.warn(` ⚠ unresolved alias: ${value}`)
|
|
266
|
+
return value
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- RADIUS ----------
|
|
270
|
+
|
|
271
|
+
function buildRadius() {
|
|
272
|
+
const tokens = readTokens('Tokens.Mode 1.tokens.json')
|
|
273
|
+
const radius = { ...RADIUS_EXTRA }
|
|
274
|
+
|
|
275
|
+
for (const [key, token] of Object.entries(tokens.radius || {})) {
|
|
276
|
+
const px = token.$value
|
|
277
|
+
radius[key] = px
|
|
278
|
+
const name = RADIUS_NAME_BY_PX[px]
|
|
279
|
+
if (name) radius[name] = px
|
|
280
|
+
}
|
|
281
|
+
return radius
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- TYPOGRAPHY ----------
|
|
285
|
+
|
|
286
|
+
// We derive the type scale from the Figma *text styles* export
|
|
287
|
+
// (`text.styles.tokens.json`), not the *variable* export
|
|
288
|
+
// (`Typography.Desktop`). Text styles carry the exact per-size pairing of size +
|
|
289
|
+
// line-height + letter-spacing (+ `uppercase` on `tiny`); the variable export
|
|
290
|
+
// rounds line-heights to px and drops per-size letter-spacing. Weights still
|
|
291
|
+
// come from FONT_WEIGHT_MAP because the text-styles weight column is corrupt
|
|
292
|
+
// (see note there).
|
|
293
|
+
|
|
294
|
+
// Figma models line-height & letter-spacing as percentages of the font size.
|
|
295
|
+
// Tailwind wants a unitless ratio for line-height and `em` for letter-spacing.
|
|
296
|
+
const pctToRatio = (v) => String(round(parseFloat(v) / 100, 4)) // "115%" -> "1.15"
|
|
297
|
+
// letter-spacing % of font size === em. `paragraph/5xl` exports as "0.5px" by an
|
|
298
|
+
// exporter bug (should be "0.5%"); parseFloat keeps the number and we treat it
|
|
299
|
+
// as a percent regardless of unit, which yields the intended value either way.
|
|
300
|
+
const lsToEm = (v) => `${round(parseFloat(v) / 100, 5)}em` // "2%" -> "0.02em"
|
|
301
|
+
|
|
302
|
+
function round(n, places) {
|
|
303
|
+
const f = 10 ** places
|
|
304
|
+
return Math.round(n * f) / f
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sizes present in the Figma text-styles export that ship no vocabulary:
|
|
308
|
+
// audited in #940 against frappe-ui's own source, docs, and stories, plus a
|
|
309
|
+
// fresh rule-5 census of every consumer app (crm, helpdesk, gameplan,
|
|
310
|
+
// insights, builder, suite, central, frappe_calendar, frappe-ui-starter,
|
|
311
|
+
// frappe's ui/ package) — zero call sites anywhere for either.
|
|
312
|
+
// - `tiny`: also excluded from the docs type-scale page's own size lists
|
|
313
|
+
// (TypographyPage.vue), so even frappe-ui's own showcase doesn't use it.
|
|
314
|
+
// - `13xl`-`16xl`: the docs "display sizes" showcase itself stops at
|
|
315
|
+
// `12xl` (see DISPLAY_KEYS in TypographyPage.vue) — these four sizes are
|
|
316
|
+
// past what even the type-scale demo cares to show.
|
|
317
|
+
const DROPPED_SIZES = ['tiny', '13xl', '14xl', '15xl', '16xl']
|
|
318
|
+
|
|
319
|
+
function buildTypography() {
|
|
320
|
+
const styles = readTokens('text.styles.tokens.json')
|
|
321
|
+
const text = Object.fromEntries(
|
|
322
|
+
Object.entries(styles.text || {}).filter(([key]) => !DROPPED_SIZES.includes(key)),
|
|
323
|
+
)
|
|
324
|
+
// Same filter as `text` above — paragraph has no dropped-size entries today
|
|
325
|
+
// (it tops out at `4xl` and never had a `tiny`), but this keeps it that way
|
|
326
|
+
// if Figma ever adds one (#940).
|
|
327
|
+
const paragraphStyles = Object.fromEntries(
|
|
328
|
+
Object.entries(styles.paragraph || {}).filter(([key]) => !DROPPED_SIZES.includes(key)),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const fontFamily = { text: text.base?.regular?.$value.fontFamily || 'Inter Variable' }
|
|
332
|
+
|
|
333
|
+
const fontWeight = {
|
|
334
|
+
regular: FONT_WEIGHT_MAP.regular,
|
|
335
|
+
medium: FONT_WEIGHT_MAP.medium,
|
|
336
|
+
semibold: FONT_WEIGHT_MAP.semibold,
|
|
337
|
+
bold: FONT_WEIGHT_MAP.bold,
|
|
338
|
+
black: FONT_WEIGHT_MAP.extrabold,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Letter-spacing is the only property that varies by weight (line-height &
|
|
342
|
+
// text-transform are constant per size). Capture it per (size, weight) so the
|
|
343
|
+
// plugin can emit `text-<size>-<weight>` classes; values are honored as-is
|
|
344
|
+
// from text.styles (the source of truth), oddities included.
|
|
345
|
+
const WEIGHTS = ['regular', 'medium', 'semibold', 'bold', 'black']
|
|
346
|
+
const trackingOf = (variants) =>
|
|
347
|
+
Object.fromEntries(
|
|
348
|
+
WEIGHTS.filter((w) => variants[w]).map((w) => [w, lsToEm(variants[w].$value.letterSpacing)]),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// Base size utilities (`text-<size>`), from each size's `regular` variant.
|
|
352
|
+
const fontSize = {}
|
|
353
|
+
const textTransform = {}
|
|
354
|
+
const tracking = { text: {}, paragraph: {} }
|
|
355
|
+
for (const [key, variants] of Object.entries(text)) {
|
|
356
|
+
const v = variants.regular.$value
|
|
357
|
+
fontSize[key] = [
|
|
358
|
+
v.fontSize,
|
|
359
|
+
{
|
|
360
|
+
lineHeight: pctToRatio(v.lineHeight),
|
|
361
|
+
letterSpacing: lsToEm(v.letterSpacing),
|
|
362
|
+
fontWeight: String(FONT_WEIGHT_MAP.regular),
|
|
363
|
+
},
|
|
364
|
+
]
|
|
365
|
+
if (v.textTransform && v.textTransform !== 'none') textTransform[key] = v.textTransform
|
|
366
|
+
tracking.text[key] = trackingOf(variants)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Paragraph variants (`text-p-<size>`) — same sizes, reading line-height/track.
|
|
370
|
+
const paragraph = {}
|
|
371
|
+
for (const [key, variants] of Object.entries(paragraphStyles)) {
|
|
372
|
+
const v = variants.regular.$value
|
|
373
|
+
paragraph[key] = {
|
|
374
|
+
lineHeight: pctToRatio(v.lineHeight),
|
|
375
|
+
letterSpacing: lsToEm(v.letterSpacing),
|
|
376
|
+
}
|
|
377
|
+
tracking.paragraph[key] = trackingOf(variants)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { fontFamily, fontWeight, fontSize, textTransform, paragraph, tracking }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------- EFFECTS (shadows) ----------
|
|
384
|
+
|
|
385
|
+
// Figma exports shadow effects as DTCG `$type: shadow` tokens — an array of
|
|
386
|
+
// layers, each with offsetX/offsetY/blur/spread/color (+ optional inset).
|
|
387
|
+
// Emit pre-composed CSS box-shadow strings so the plugin can drop them into
|
|
388
|
+
// CSS variables verbatim.
|
|
389
|
+
// Custom elevation names present in Figma but with zero call sites anywhere
|
|
390
|
+
// (#940 rule-5 census: frappe-ui's own source/docs/stories, all 9 consumer
|
|
391
|
+
// apps, and frappe's ui/ package). `status` isn't even wired into the docs'
|
|
392
|
+
// own elevation showcase (ElevationPreview.vue renders only the six numbered
|
|
393
|
+
// steps) — it's named in prose once and never rendered.
|
|
394
|
+
const DROPPED_CUSTOM_ELEVATIONS = ['status']
|
|
395
|
+
|
|
396
|
+
function buildEffects() {
|
|
397
|
+
const tokens = readTokens('effect.styles.tokens.json')
|
|
398
|
+
const out = {
|
|
399
|
+
elevation: { light: {}, dark: {}, custom: {} },
|
|
400
|
+
focus: { light: {}, dark: {} },
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const step of Object.keys(tokens.elevation?.light || {})) {
|
|
404
|
+
out.elevation.light[step] = shadowToCss(tokens.elevation.light[step].$value)
|
|
405
|
+
}
|
|
406
|
+
for (const step of Object.keys(tokens.elevation?.dark || {})) {
|
|
407
|
+
out.elevation.dark[step] = shadowToCss(tokens.elevation.dark[step].$value)
|
|
408
|
+
}
|
|
409
|
+
for (const [name, token] of Object.entries(tokens.elevation?.custom || {})) {
|
|
410
|
+
if (DROPPED_CUSTOM_ELEVATIONS.includes(name)) continue
|
|
411
|
+
out.elevation.custom[name] = shadowToCss(token.$value)
|
|
412
|
+
}
|
|
413
|
+
for (const [name, token] of Object.entries(tokens.focus?.light || {})) {
|
|
414
|
+
out.focus.light[name] = shadowToCss(token.$value)
|
|
415
|
+
}
|
|
416
|
+
for (const [name, token] of Object.entries(tokens.focus?.dark || {})) {
|
|
417
|
+
out.focus.dark[name] = shadowToCss(token.$value)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Figma paints its effect array back-to-front (index 0 = bottom of the stack),
|
|
424
|
+
// while CSS box-shadow paints front-to-back (first listed = on top). Reverse the
|
|
425
|
+
// layers so the composed CSS string matches Figma's visual stacking order.
|
|
426
|
+
function shadowToCss(layers) {
|
|
427
|
+
return layers
|
|
428
|
+
.slice()
|
|
429
|
+
.reverse()
|
|
430
|
+
.map((layer) => {
|
|
431
|
+
const parts = [
|
|
432
|
+
layer.inset ? 'inset' : null,
|
|
433
|
+
layer.offsetX,
|
|
434
|
+
layer.offsetY,
|
|
435
|
+
layer.blur,
|
|
436
|
+
layer.spread || '0px',
|
|
437
|
+
layer.color,
|
|
438
|
+
].filter(Boolean)
|
|
439
|
+
return parts.join(' ')
|
|
440
|
+
})
|
|
441
|
+
.join(', ')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------- MAIN ----------
|
|
445
|
+
|
|
446
|
+
function main() {
|
|
447
|
+
if (!fs.existsSync(TOKENS_DIR)) {
|
|
448
|
+
console.error(`✗ tokens directory not found: ${TOKENS_DIR}`)
|
|
449
|
+
process.exit(1)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
console.log(`Reading tokens from ${path.relative(REPO_ROOT, TOKENS_DIR)}/`)
|
|
453
|
+
ensureOutDir()
|
|
454
|
+
|
|
455
|
+
writeJSON('colors.json', buildColors())
|
|
456
|
+
writeJSON('radius.json', buildRadius())
|
|
457
|
+
writeJSON('typography.json', buildTypography())
|
|
458
|
+
writeJSON('effects.json', buildEffects())
|
|
459
|
+
|
|
460
|
+
// colors.json is consumed from tailwind/ (top-level) by colorPalette.js, while
|
|
461
|
+
// the generator emits to tailwind/generated/. Copy it up so `yarn sync-tokens`
|
|
462
|
+
// is the single source of truth (no manual copy step).
|
|
463
|
+
fs.copyFileSync(path.join(OUT_DIR, 'colors.json'), path.join(__dirname, 'colors.json'))
|
|
464
|
+
|
|
465
|
+
console.log('✓ done')
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
469
|
+
const invokedPath = process.argv[1]
|
|
470
|
+
const isCLI = invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
|
|
471
|
+
if (isCLI) main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hexToOklch, toOklch } from './figma-tokens-to-theme.js'
|
|
3
|
+
|
|
4
|
+
describe('hexToOklch', () => {
|
|
5
|
+
it('converts a 6-digit hex', () => {
|
|
6
|
+
expect(hexToOklch('#ffffff')).toBe('oklch(1 0 0)')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('converts an 8-digit hex with alpha', () => {
|
|
10
|
+
expect(hexToOklch('#00000080')).toBe('oklch(0 0 0 / 0.502)')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('throws on shorthand hex instead of emitting NaN', () => {
|
|
14
|
+
expect(() => hexToOklch('#fff')).toThrow(/#rrggbb/)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('toOklch', () => {
|
|
19
|
+
it('passes alias references through untouched', () => {
|
|
20
|
+
expect(toOklch('{light.gray.50}')).toBe('{light.gray.50}')
|
|
21
|
+
})
|
|
22
|
+
})
|