@talelk/newui-tailwind 0.0.0-stage → 1.0.2
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 +9 -2
- package/package.json +44 -4
- package/src/contrast.test.ts +217 -0
- package/src/fonts/Inter/Inter-Italic-cjk.var.woff2 +0 -0
- package/src/fonts/Inter/Inter-Italic-extended.var.woff2 +0 -0
- package/src/fonts/Inter/Inter-Italic-latin.var.woff2 +0 -0
- package/src/fonts/Inter/Inter-cjk.var.woff2 +0 -0
- package/src/fonts/Inter/Inter-extended.var.woff2 +0 -0
- package/src/fonts/Inter/Inter-latin.var.woff2 +0 -0
- package/src/fonts/Inter/LICENSE.txt +92 -0
- package/src/fonts/Inter/inter.css +114 -0
- package/src/generated/theme.css +1506 -0
- package/src/generated/tokens.css +1103 -0
- package/src/generated.test.ts +161 -0
- package/src/keyframes.test.ts +67 -0
- package/src/plugins/icon-pack.js +87 -0
- package/src/plugins/lucide.js +28 -0
- package/src/plugins/theme.js +89 -0
- package/src/source.d.ts +1 -0
- package/src/source.ts +23 -0
- package/src/style.css +41 -0
- package/src/tokens.test.ts +145 -0
- package/src/utilities.css +177 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
colors,
|
|
5
|
+
duration,
|
|
6
|
+
easing,
|
|
7
|
+
fontSize,
|
|
8
|
+
radius,
|
|
9
|
+
screens,
|
|
10
|
+
semanticColors,
|
|
11
|
+
shadowRoles,
|
|
12
|
+
shadows,
|
|
13
|
+
spacing,
|
|
14
|
+
tracking,
|
|
15
|
+
zIndex,
|
|
16
|
+
} from '@talelk/newui-tokens'
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generator parity: `@talelk/newui-tokens` data ↔ generated `@theme`.
|
|
21
|
+
*
|
|
22
|
+
* `scripts/build-css.ts` derives `theme.css` from the token data, so the
|
|
23
|
+
* expected vocabulary is derived here the same way instead of hand-listed.
|
|
24
|
+
* A renamed generator branch or a dropped token family then fails here
|
|
25
|
+
* instead of silently compiling a component's utility to nothing.
|
|
26
|
+
*
|
|
27
|
+
* This is the data→CSS direction only. The reverse direction (every token a
|
|
28
|
+
* component *references* exists) is `tokens.test.ts`; nothing is asserted
|
|
29
|
+
* twice.
|
|
30
|
+
*/
|
|
31
|
+
const themeCss = readFileSync(
|
|
32
|
+
path.join(process.cwd(), 'packages/tailwind/src/generated/theme.css'),
|
|
33
|
+
'utf8',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Every custom property theme.css declares (`--spacing-0.5` needs the dot).
|
|
37
|
+
const defined = new Set<string>(
|
|
38
|
+
[...themeCss.matchAll(/^\s*(--[a-z0-9.-]+):/gm)].flatMap((m) =>
|
|
39
|
+
m[1] ? [m[1]] : [],
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Mirrors build-css.ts: semantic categories, primitive + dark ramps, overlay
|
|
44
|
+
// ramps, then the hand-added chart series (build-css.ts chart branch).
|
|
45
|
+
function expectedColors(): Set<string> {
|
|
46
|
+
const names = new Set<string>()
|
|
47
|
+
for (const [category, entries] of Object.entries(semanticColors.light)) {
|
|
48
|
+
for (const name of Object.keys(entries)) {
|
|
49
|
+
names.add(`--color-${category}-${name}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [family, shades] of Object.entries(colors.light)) {
|
|
53
|
+
for (const shade of Object.keys(shades)) {
|
|
54
|
+
names.add(`--color-${family}-${shade}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [family, shades] of Object.entries(colors.dark)) {
|
|
58
|
+
for (const shade of Object.keys(shades)) {
|
|
59
|
+
names.add(`--color-dark-${family}-${shade}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [name, shades] of Object.entries(colors.overlay)) {
|
|
63
|
+
if (typeof shades !== 'object') continue
|
|
64
|
+
for (const shade of Object.keys(shades)) {
|
|
65
|
+
names.add(`--color-${name}-overlay-${shade}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (let index = 1; index <= 8; index += 1) {
|
|
69
|
+
names.add(`--color-chart-${index}`)
|
|
70
|
+
}
|
|
71
|
+
names.add('--color-chart-grid')
|
|
72
|
+
names.add('--color-chart-axis')
|
|
73
|
+
return names
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function expectedShadows(): Set<string> {
|
|
77
|
+
const names = new Set<string>()
|
|
78
|
+
for (const key of Object.keys(shadows)) {
|
|
79
|
+
if (key === 'none') continue
|
|
80
|
+
names.add(key === 'DEFAULT' ? '--shadow' : `--shadow-${key}`)
|
|
81
|
+
}
|
|
82
|
+
for (const role of Object.keys(shadowRoles)) {
|
|
83
|
+
names.add(`--shadow-${role}`)
|
|
84
|
+
}
|
|
85
|
+
return names
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const WEIGHTS = ['medium', 'semibold', 'bold'] as const
|
|
89
|
+
|
|
90
|
+
// `--text-<size>` always; `--text-<size>-<weight>` only where the tracking
|
|
91
|
+
// table has an entry for that size+weight (build-css.ts typography branch).
|
|
92
|
+
function expectedText(): Set<string> {
|
|
93
|
+
const names = new Set<string>()
|
|
94
|
+
for (const key of Object.keys(fontSize)) {
|
|
95
|
+
names.add(`--text-${key}`)
|
|
96
|
+
const isParagraph = key.startsWith('p-')
|
|
97
|
+
const group = isParagraph ? 'paragraph' : 'text'
|
|
98
|
+
const size = isParagraph ? key.slice(2) : key
|
|
99
|
+
const byWeight = tracking[group]?.[size]
|
|
100
|
+
if (!byWeight) continue
|
|
101
|
+
for (const weight of WEIGHTS) {
|
|
102
|
+
if (byWeight[weight] !== undefined) names.add(`--text-${key}-${weight}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return names
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('generated theme parity with @talelk/newui-tokens', () => {
|
|
109
|
+
it('emits every derived --color-* token', () => {
|
|
110
|
+
const missing = [...expectedColors()].filter((name) => !defined.has(name))
|
|
111
|
+
expect(missing).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('defines no --color-* token the data does not derive', () => {
|
|
115
|
+
const expected = expectedColors()
|
|
116
|
+
const extra = [...defined].filter(
|
|
117
|
+
(name) => name.startsWith('--color-') && !expected.has(name),
|
|
118
|
+
)
|
|
119
|
+
expect(extra).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('emits every radius, spacing and shadow token', () => {
|
|
123
|
+
const expected = [
|
|
124
|
+
...Object.keys(radius).map((key) => `--radius-${key}`),
|
|
125
|
+
...Object.keys(spacing).map((key) => `--spacing-${key}`),
|
|
126
|
+
...expectedShadows(),
|
|
127
|
+
]
|
|
128
|
+
const missing = expected.filter((name) => !defined.has(name))
|
|
129
|
+
expect(missing).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('emits every derived text-size token', () => {
|
|
133
|
+
const missing = [...expectedText()].filter((name) => !defined.has(name))
|
|
134
|
+
expect(missing).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('emits the stacking and motion scales', () => {
|
|
138
|
+
const expected = [
|
|
139
|
+
...Object.keys(zIndex).map((key) => `--z-${key}`),
|
|
140
|
+
...Object.keys(duration).map((key) => `--duration-${key}`),
|
|
141
|
+
...Object.keys(easing).map((key) => `--ease-${key}`),
|
|
142
|
+
]
|
|
143
|
+
const missing = expected.filter((name) => !defined.has(name))
|
|
144
|
+
expect(missing).toEqual([])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('emits the breakpoint, animation and mono scales', () => {
|
|
148
|
+
// The animation names are composed in build-css.ts from the duration and
|
|
149
|
+
// easing tokens; the list is mirrored here so a dropped branch fails.
|
|
150
|
+
const expected = [
|
|
151
|
+
...Object.keys(screens).map((key) => `--breakpoint-${key}`),
|
|
152
|
+
'--animate-overlay-in',
|
|
153
|
+
'--animate-drawer-in',
|
|
154
|
+
'--animate-shimmer',
|
|
155
|
+
'--animate-progress',
|
|
156
|
+
'--font-mono',
|
|
157
|
+
]
|
|
158
|
+
const missing = expected.filter((name) => !defined.has(name))
|
|
159
|
+
expect(missing).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Referenced-but-undefined animation guard.
|
|
7
|
+
*
|
|
8
|
+
* A component can reference `animate-[name_…]` while no `@keyframes name`
|
|
9
|
+
* exists anywhere — the class compiles, the utility is emitted, and the element
|
|
10
|
+
* simply never animates. That is a silent no-op no other test catches (it is how
|
|
11
|
+
* the indeterminate Progress bar shipped dead once). This asserts every custom
|
|
12
|
+
* animation a component names is actually defined.
|
|
13
|
+
*/
|
|
14
|
+
const TAILWIND_SRC = path.join(process.cwd(), 'packages/tailwind/src')
|
|
15
|
+
const VISUAL_SRCS = [
|
|
16
|
+
'atoms',
|
|
17
|
+
'molecules',
|
|
18
|
+
'organisms',
|
|
19
|
+
'templates',
|
|
20
|
+
'charts',
|
|
21
|
+
'editor',
|
|
22
|
+
'code-editor',
|
|
23
|
+
].map((pkg) => path.join(process.cwd(), `packages/${pkg}/src`))
|
|
24
|
+
const TAILWIND_BUILTINS = new Set(['spin', 'pulse', 'ping', 'bounce'])
|
|
25
|
+
|
|
26
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
27
|
+
for (const entry of readdirSync(dir)) {
|
|
28
|
+
const full = path.join(dir, entry)
|
|
29
|
+
if (statSync(full).isDirectory()) walk(full, out)
|
|
30
|
+
else if (/\.(tsx?|css)$/.test(entry)) out.push(full)
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const defined = new Set<string>()
|
|
36
|
+
for (const file of walk(TAILWIND_SRC)) {
|
|
37
|
+
const source = readFileSync(file, 'utf8')
|
|
38
|
+
for (const match of source.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
39
|
+
const name = match[1]
|
|
40
|
+
if (name) defined.add(name)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const referenced = new Map<string, string>()
|
|
45
|
+
for (const file of VISUAL_SRCS.flatMap((dir) => walk(dir))) {
|
|
46
|
+
const source = readFileSync(file, 'utf8')
|
|
47
|
+
for (const match of source.matchAll(/animate-\[([\w-]+?)_/g)) {
|
|
48
|
+
const name = match[1]
|
|
49
|
+
if (name) referenced.set(name, path.relative(process.cwd(), file))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('animation keyframes', () => {
|
|
54
|
+
it('defines every custom animation a component references', () => {
|
|
55
|
+
const missing: string[] = []
|
|
56
|
+
for (const [name, file] of referenced) {
|
|
57
|
+
if (TAILWIND_BUILTINS.has(name)) continue
|
|
58
|
+
if (!defined.has(name)) missing.push(`${name} (referenced in ${file})`)
|
|
59
|
+
}
|
|
60
|
+
expect(missing).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('respects prefers-reduced-motion', () => {
|
|
64
|
+
const css = readFileSync(path.join(TAILWIND_SRC, 'utilities.css'), 'utf8')
|
|
65
|
+
expect(css).toContain('@media (prefers-reduced-motion: reduce)')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import plugin from 'tailwindcss/plugin'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Exposes every SVG in `iconsDir` as a `<prefix>-<name>` utility that masks the
|
|
7
|
+
* icon with the current text color:
|
|
8
|
+
*
|
|
9
|
+
* <span class="lucide-menu size-4 text-ink-gray-6" />
|
|
10
|
+
*
|
|
11
|
+
* Tailwind v4 removed `matchComponents`, so this registers as a normal utility
|
|
12
|
+
* (`matchUtilities`). The icon utility deliberately sets NO width/height — the
|
|
13
|
+
* call site always pairs it with a `size-*` — which dodges the layer-order race
|
|
14
|
+
* v3 solved by putting icons in the components layer.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.prefix
|
|
18
|
+
* @param {string} options.iconsDir
|
|
19
|
+
* @param {number} [options.normalizeStrokeWidth]
|
|
20
|
+
* @param {string} [options.defaultColor]
|
|
21
|
+
*/
|
|
22
|
+
export function iconPackPlugin({
|
|
23
|
+
prefix,
|
|
24
|
+
iconsDir,
|
|
25
|
+
normalizeStrokeWidth,
|
|
26
|
+
defaultColor,
|
|
27
|
+
}) {
|
|
28
|
+
if (!prefix) throw new Error('iconPackPlugin: `prefix` is required')
|
|
29
|
+
if (!iconsDir) throw new Error('iconPackPlugin: `iconsDir` is required')
|
|
30
|
+
|
|
31
|
+
const cache = new Map()
|
|
32
|
+
|
|
33
|
+
function encode(name) {
|
|
34
|
+
if (cache.has(name)) return cache.get(name)
|
|
35
|
+
const file = path.join(iconsDir, `${name}.svg`)
|
|
36
|
+
let uri = null
|
|
37
|
+
if (fs.existsSync(file)) {
|
|
38
|
+
let svg = fs.readFileSync(file, 'utf8')
|
|
39
|
+
if (normalizeStrokeWidth != null) {
|
|
40
|
+
svg = svg.replace(
|
|
41
|
+
/stroke-width="[^"]+"/g,
|
|
42
|
+
`stroke-width="${normalizeStrokeWidth}"`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
svg = svg.replace(/\s+/g, ' ').trim()
|
|
46
|
+
uri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`
|
|
47
|
+
}
|
|
48
|
+
cache.set(name, uri)
|
|
49
|
+
return uri
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function available() {
|
|
53
|
+
try {
|
|
54
|
+
return fs
|
|
55
|
+
.readdirSync(iconsDir)
|
|
56
|
+
.filter((f) => f.endsWith('.svg'))
|
|
57
|
+
.map((f) => f.slice(0, -4))
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return plugin(({ matchUtilities }) => {
|
|
64
|
+
const values = Object.fromEntries(available().map((n) => [n, n]))
|
|
65
|
+
matchUtilities(
|
|
66
|
+
{
|
|
67
|
+
[prefix]: (value) => {
|
|
68
|
+
const mask = encode(value)
|
|
69
|
+
if (!mask) return {}
|
|
70
|
+
return {
|
|
71
|
+
display: 'block',
|
|
72
|
+
'background-color': defaultColor ?? 'currentColor',
|
|
73
|
+
'-webkit-mask-image': mask,
|
|
74
|
+
'mask-image': mask,
|
|
75
|
+
'-webkit-mask-repeat': 'no-repeat',
|
|
76
|
+
'mask-repeat': 'no-repeat',
|
|
77
|
+
'-webkit-mask-position': 'center',
|
|
78
|
+
'mask-position': 'center',
|
|
79
|
+
'-webkit-mask-size': 'contain',
|
|
80
|
+
'mask-size': 'contain',
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{ values },
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import plugin from 'tailwindcss/plugin'
|
|
4
|
+
import { iconPackPlugin } from './icon-pack.js'
|
|
5
|
+
|
|
6
|
+
// Resolve lucide-static's icons directory once. Guarded: a consumer that does
|
|
7
|
+
// not install lucide-static should still get a working stylesheet, just with
|
|
8
|
+
// no `lucide-*` utilities.
|
|
9
|
+
let iconsDir = null
|
|
10
|
+
try {
|
|
11
|
+
const nodeRequire = createRequire(import.meta.url)
|
|
12
|
+
iconsDir = path.join(
|
|
13
|
+
path.dirname(nodeRequire.resolve('lucide-static/package.json')),
|
|
14
|
+
'icons',
|
|
15
|
+
)
|
|
16
|
+
} catch {
|
|
17
|
+
iconsDir = null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Lucide ships every icon at stroke-width="2"; 1.5 matches the rest of the
|
|
21
|
+
// design system. No defaultColor — icons inherit the parent's text color.
|
|
22
|
+
export default iconsDir
|
|
23
|
+
? iconPackPlugin({
|
|
24
|
+
prefix: 'lucide',
|
|
25
|
+
iconsDir,
|
|
26
|
+
normalizeStrokeWidth: 1.5,
|
|
27
|
+
})
|
|
28
|
+
: plugin(() => {})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import plugin from 'tailwindcss/plugin'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base layer for the design system.
|
|
5
|
+
*
|
|
6
|
+
* In the v3 layer (frappe-ui's `plugin.js`) this also minted the color theme,
|
|
7
|
+
* text styles and focus-ring components. In v4 all of those come from
|
|
8
|
+
* `@theme` (generated) and `@utility` (utilities.css), so this plugin only
|
|
9
|
+
* carries what genuinely has to live in the base layer.
|
|
10
|
+
*/
|
|
11
|
+
export default plugin(({ addBase }) => {
|
|
12
|
+
addBase({
|
|
13
|
+
// `@tailwindcss/forms` is loaded before this plugin and resets every native
|
|
14
|
+
// text control: a 1px `gray-500` border, a white fill, `padding: 0.5rem
|
|
15
|
+
// 0.75rem`, a 16px font and a blue focus ring. Our recipes own all of that,
|
|
16
|
+
// so the reset only ever leaks into the controls that deliberately want
|
|
17
|
+
// none of it — the bare `<input>` inside Combobox / MultiSelect /
|
|
18
|
+
// DatePicker / TimePicker / DateRangePicker / DateTimePicker, which
|
|
19
|
+
// otherwise renders as a bordered 16px box inside the field's own 14px
|
|
20
|
+
// surface. Neutralised once, here, rather than restated in every recipe;
|
|
21
|
+
// utilities still win, so a recipe that asks for a border, padding or a
|
|
22
|
+
// size gets it. Checkboxes, radios and file inputs are left to the plugin —
|
|
23
|
+
// those *are* its widgets.
|
|
24
|
+
"input:not([type='checkbox'], [type='radio'], [type='file']), textarea, select":
|
|
25
|
+
{
|
|
26
|
+
'border-width': '0',
|
|
27
|
+
'border-radius': '0',
|
|
28
|
+
'background-color': 'transparent',
|
|
29
|
+
padding: '0',
|
|
30
|
+
'font-size': 'inherit',
|
|
31
|
+
'line-height': 'inherit',
|
|
32
|
+
},
|
|
33
|
+
// The plugin's `:focus` rule paints a 1px blue ring on the control itself.
|
|
34
|
+
// Focus indication belongs to the field (`focus-within:focus-ring`), so the
|
|
35
|
+
// inner control must not draw a second, clipped ring.
|
|
36
|
+
"input:not([type='checkbox'], [type='radio'], [type='file']):focus, textarea:focus, select:focus":
|
|
37
|
+
{
|
|
38
|
+
'box-shadow': 'none',
|
|
39
|
+
},
|
|
40
|
+
body: {
|
|
41
|
+
'font-size': 'var(--text-interface)',
|
|
42
|
+
'line-height': 'var(--text-interface--line-height)',
|
|
43
|
+
},
|
|
44
|
+
html: {
|
|
45
|
+
'font-family': 'InterVar, ui-sans-serif, system-ui, sans-serif',
|
|
46
|
+
'font-optical-sizing': 'auto',
|
|
47
|
+
},
|
|
48
|
+
'html, body, button, p, span, div': {
|
|
49
|
+
fontVariationSettings: "'opsz' 24, 'cv11' 1",
|
|
50
|
+
WebkitFontSmoothing: 'antialiased',
|
|
51
|
+
MozOsxFontSmoothing: 'grayscale',
|
|
52
|
+
},
|
|
53
|
+
// A bare <p> reads as body copy. At element specificity (0,0,1) with
|
|
54
|
+
// :where() so any explicit text-* / text-p-* line-height wins.
|
|
55
|
+
':where(p)': {
|
|
56
|
+
'line-height': '1.6',
|
|
57
|
+
},
|
|
58
|
+
// The baseline focus ring, keyboard-only. `:where()` gives it zero
|
|
59
|
+
// specificity, so a component's own `focus-ring` utility (or an explicit
|
|
60
|
+
// `outline-none`) still wins — but every interactive element that forgot
|
|
61
|
+
// to opt in gets a visible ring instead of the browser default.
|
|
62
|
+
':where(:focus-visible)': {
|
|
63
|
+
outline: 'var(--focus-outline-default)',
|
|
64
|
+
'outline-offset': '0px',
|
|
65
|
+
},
|
|
66
|
+
// Tell the browser which scheme native UI (scrollbars, form controls,
|
|
67
|
+
// autofill) should render. Without this, a dark page keeps light
|
|
68
|
+
// scrollbars and light form widgets.
|
|
69
|
+
':root': {
|
|
70
|
+
'color-scheme': 'light',
|
|
71
|
+
},
|
|
72
|
+
'[data-theme="dark"]': {
|
|
73
|
+
'color-scheme': 'dark',
|
|
74
|
+
},
|
|
75
|
+
// Selection reads as a tinted brand highlight rather than the OS default.
|
|
76
|
+
'::selection': {
|
|
77
|
+
'background-color':
|
|
78
|
+
'color-mix(in oklab, var(--surface-blue-6) 28%, transparent)',
|
|
79
|
+
},
|
|
80
|
+
// `useColorScheme` stamps this for the two frames around a light/dark swap
|
|
81
|
+
// so every transitioning surface does not cross-fade at once (a flash).
|
|
82
|
+
'.no-transition, .no-transition *': {
|
|
83
|
+
transition: 'none !important',
|
|
84
|
+
},
|
|
85
|
+
'.no-transition *::before, .no-transition *::after': {
|
|
86
|
+
transition: 'none !important',
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
})
|
package/src/source.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const source: string[]
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source globs that emit Tailwind classes in newui. A consuming app spreads
|
|
3
|
+
* these into its own `@source` list (Tailwind v4 scans sources declared in
|
|
4
|
+
* CSS, not a JS config):
|
|
5
|
+
*
|
|
6
|
+
* @source '@talelk/newui-tailwind/source';
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(
|
|
12
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
13
|
+
'../..',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
function glob(pattern: string) {
|
|
17
|
+
return path.join(packageRoot, pattern).split(path.sep).join('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const source = [
|
|
21
|
+
glob('ui/src/**/*.{ts,tsx}'),
|
|
22
|
+
glob('icons/src/**/*.{ts,tsx}'),
|
|
23
|
+
]
|
package/src/style.css
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* The single stylesheet an app imports.
|
|
2
|
+
*
|
|
3
|
+
* @import '@talelk/newui-tailwind/style.css';
|
|
4
|
+
*
|
|
5
|
+
* Everything below is generated or framework-neutral; nothing here is
|
|
6
|
+
* app-specific. The consuming app adds its own `@source` globs. */
|
|
7
|
+
@import "./fonts/Inter/inter.css";
|
|
8
|
+
@import "tailwindcss";
|
|
9
|
+
@import "./generated/tokens.css";
|
|
10
|
+
@import "./generated/theme.css";
|
|
11
|
+
@import "./utilities.css";
|
|
12
|
+
|
|
13
|
+
@plugin "@tailwindcss/forms";
|
|
14
|
+
@plugin "@tailwindcss/typography";
|
|
15
|
+
@plugin "./plugins/theme.js";
|
|
16
|
+
@plugin "./plugins/lucide.js";
|
|
17
|
+
|
|
18
|
+
/* Dark mode is the document switch, not a per-component variant. Color
|
|
19
|
+
* utilities already flip through the runtime tokens; this variant exists for
|
|
20
|
+
* the occasional `dark:` edge case. */
|
|
21
|
+
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
22
|
+
|
|
23
|
+
/* Scan every atomic visual package so a component's classes always emit.
|
|
24
|
+
* `not` excludes the merge config (vocabulary, not usage) and each package's
|
|
25
|
+
* test fixtures, whose strings are not component markup. */
|
|
26
|
+
@source "../../atoms/src/**/*.{ts,tsx}";
|
|
27
|
+
@source not "../../atoms/src/lib/tw-merge.config.ts";
|
|
28
|
+
@source not "../../atoms/src/**/*.test.{ts,tsx}";
|
|
29
|
+
@source "../../molecules/src/**/*.{ts,tsx}";
|
|
30
|
+
@source not "../../molecules/src/**/*.test.{ts,tsx}";
|
|
31
|
+
@source "../../icons/src/**/*.{ts,tsx}";
|
|
32
|
+
@source "../../organisms/src/**/*.{ts,tsx}";
|
|
33
|
+
@source not "../../organisms/src/**/*.test.{ts,tsx}";
|
|
34
|
+
@source "../../templates/src/**/*.{ts,tsx}";
|
|
35
|
+
@source not "../../templates/src/**/*.test.{ts,tsx}";
|
|
36
|
+
@source "../../charts/src/**/*.{ts,tsx}";
|
|
37
|
+
@source not "../../charts/src/**/*.test.{ts,tsx}";
|
|
38
|
+
@source "../../editor/src/**/*.{ts,tsx}";
|
|
39
|
+
@source not "../../editor/src/**/*.test.{ts,tsx}";
|
|
40
|
+
@source "../../code-editor/src/**/*.{ts,tsx}";
|
|
41
|
+
@source not "../../code-editor/src/**/*.test.{ts,tsx}";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { layoutSpacing, radiusRoles } from '@talelk/newui-tokens'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Token discipline.
|
|
8
|
+
*
|
|
9
|
+
* Two failure modes this catches, both of which have shipped here before:
|
|
10
|
+
*
|
|
11
|
+
* 1. A component referencing a semantic token that does not exist
|
|
12
|
+
* (`bg-surface-modal`) — the utility silently compiles to nothing and the
|
|
13
|
+
* element renders transparent.
|
|
14
|
+
* 2. A component reaching for a raw Tailwind palette colour
|
|
15
|
+
* (`bg-red-500`) instead of the semantic vocabulary (P: semantic tokens
|
|
16
|
+
* only). Brand customization and dark mode both depend on this.
|
|
17
|
+
*/
|
|
18
|
+
const REPO = process.cwd()
|
|
19
|
+
const VISUAL_SRCS = [
|
|
20
|
+
'atoms',
|
|
21
|
+
'molecules',
|
|
22
|
+
'organisms',
|
|
23
|
+
'templates',
|
|
24
|
+
'charts',
|
|
25
|
+
'editor',
|
|
26
|
+
'code-editor',
|
|
27
|
+
].map((pkg) => path.join(REPO, `packages/${pkg}/src`))
|
|
28
|
+
const THEME_CSS = path.join(REPO, 'packages/tailwind/src/generated/theme.css')
|
|
29
|
+
|
|
30
|
+
const themeCss = readFileSync(THEME_CSS, 'utf8')
|
|
31
|
+
const definedColors = new Set(
|
|
32
|
+
[...themeCss.matchAll(/--color-([a-z0-9-]+):/g)].map((m) => m[1]),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const TOKENS_CSS = path.join(REPO, 'packages/tailwind/src/generated/tokens.css')
|
|
36
|
+
const tokensCss = readFileSync(TOKENS_CSS, 'utf8')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every custom property tokens.css *declares* — both sides of the `name: value`.
|
|
40
|
+
* A pointer at an undeclared property is the chart-palette bug: `--chart-1:
|
|
41
|
+
* var(--blue-6)` named a shade that does not exist, so the property was
|
|
42
|
+
* guaranteed-invalid, every `var(--chart-1)` fell back to `transparent`, and
|
|
43
|
+
* charts and the palette swatches rendered invisible with no error anywhere.
|
|
44
|
+
*/
|
|
45
|
+
const declaredVars = new Set(
|
|
46
|
+
[...tokensCss.matchAll(/^\s*(--[a-z0-9-]+):/gm)].map((m) => m[1]),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const SEMANTIC_PREFIX = '(?:surface|ink|outline|black-overlay|white-overlay)'
|
|
50
|
+
const COLOR_UTILITIES =
|
|
51
|
+
'(?:bg|text|border|outline|fill|stroke|divide|ring|decoration|placeholder|caret|accent|from|via|to|shadow)'
|
|
52
|
+
|
|
53
|
+
// A raw palette ramp, e.g. `bg-red-500`, `text-gray-700`. Semantic uses are
|
|
54
|
+
// always prefixed (`bg-surface-red-2`), so a bare family+shade is the smell.
|
|
55
|
+
const PALETTE_FAMILIES =
|
|
56
|
+
'(?:gray|slate|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)'
|
|
57
|
+
const RAW_PALETTE = new RegExp(
|
|
58
|
+
`${COLOR_UTILITIES}-${PALETTE_FAMILIES}-\\d{2,3}\\b`,
|
|
59
|
+
'g',
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// Colours with no semantic equivalent that are legitimately used raw.
|
|
63
|
+
const RAW_ALLOWLIST = new Set(['text-white', 'text-black'])
|
|
64
|
+
|
|
65
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
66
|
+
for (const entry of readdirSync(dir)) {
|
|
67
|
+
const full = path.join(dir, entry)
|
|
68
|
+
if (statSync(full).isDirectory()) walk(full, out)
|
|
69
|
+
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry))
|
|
70
|
+
out.push(full)
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const files = VISUAL_SRCS.flatMap((dir) => walk(dir))
|
|
76
|
+
|
|
77
|
+
function scan(regex: RegExp): Map<string, string> {
|
|
78
|
+
const found = new Map<string, string>()
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const source = readFileSync(file, 'utf8')
|
|
81
|
+
for (const match of source.matchAll(regex)) {
|
|
82
|
+
found.set(match[0], path.relative(REPO, file))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return found
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('token discipline', () => {
|
|
89
|
+
it('emits the semantic layout spacing and radius roles', () => {
|
|
90
|
+
for (const name of Object.keys(layoutSpacing))
|
|
91
|
+
expect(themeCss).toContain(`--spacing-${name}:`)
|
|
92
|
+
for (const name of Object.keys(radiusRoles))
|
|
93
|
+
expect(themeCss).toContain(`--radius-${name}:`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('only references semantic tokens that exist in the theme', () => {
|
|
97
|
+
const semanticUse = new RegExp(
|
|
98
|
+
`${COLOR_UTILITIES}-(${SEMANTIC_PREFIX}-[a-z0-9-]+)`,
|
|
99
|
+
'g',
|
|
100
|
+
)
|
|
101
|
+
const missing: string[] = []
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const source = readFileSync(file, 'utf8')
|
|
104
|
+
for (const match of source.matchAll(semanticUse)) {
|
|
105
|
+
const token = match[1]
|
|
106
|
+
if (!definedColors.has(token)) {
|
|
107
|
+
missing.push(
|
|
108
|
+
`${match[0]} → --color-${token} (in ${path.relative(REPO, file)})`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
expect(missing).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not use raw Tailwind palette colours', () => {
|
|
117
|
+
const offenders = [...scan(RAW_PALETTE)].filter(
|
|
118
|
+
([token]) => !RAW_ALLOWLIST.has(token),
|
|
119
|
+
)
|
|
120
|
+
expect(offenders).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not hard-code colours in class strings', () => {
|
|
124
|
+
// A literal hex/rgb/hsl value cannot follow the theme, so it breaks dark
|
|
125
|
+
// mode and brand customization. Colours come from tokens, always.
|
|
126
|
+
const HARDCODED =
|
|
127
|
+
/(?:bg|text|border|ring|fill|stroke|outline|shadow|from|via|to|decoration|caret|accent)-\[(?:#|rgba?\(|hsla?\()/g
|
|
128
|
+
const offenders = [...scan(HARDCODED)]
|
|
129
|
+
expect(offenders).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('every runtime token pointer names a token that is declared', () => {
|
|
133
|
+
// `--x: var(--y)` where `--y` is declared nowhere is worse than a typo: it
|
|
134
|
+
// is silently valid CSS that makes every consumer transparent. Catches the
|
|
135
|
+
// whole class, not just the chart ramp that shipped broken.
|
|
136
|
+
const missing: string[] = []
|
|
137
|
+
for (const match of tokensCss.matchAll(
|
|
138
|
+
/^\s*(--[a-z0-9-]+):\s*var\((--[a-z0-9-]+)\)/gm,
|
|
139
|
+
)) {
|
|
140
|
+
const [, name, target] = match
|
|
141
|
+
if (!declaredVars.has(target)) missing.push(`${name} → ${target}`)
|
|
142
|
+
}
|
|
143
|
+
expect(missing).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
})
|