Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .cursor/skills/nutui-build-local-verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
name: nutui-build-local-verify
description: NutUI 比例缩放本地验证——写回 src/packages 下同路径组件 SCSS(跳过 src/packages/**/demo.scss 与 demos);--mirror 写 scale-verify/;不写 build。
disable-model-invocation: true
---

# NutUI Build Local Verify

## 在做什么

**只做一步**:用 `scripts/px-to-scale-px-in-component-scss.cjs` 把组件 SCSS 里裸 `px` 转成 `scale-px` 等,并把结果写回磁盘。

**不扫描、不写入**:**`src/packages/<组件名>/demo.scss`**(各组件目录根下的单文件)、`**/demos/**`、路径中含 **`/demo/`**、测试与快照目录下的 `.scss`(与官方 `build.mjs` 里对 `**/demo.scss` 的 ignore 一致)。

- **默认(就地覆盖)**:对每个匹配的 `.scss`,**读、写都是同一路径**——相对 `src/packages` 的路径不变。例如 `src/packages/actionsheet/actionsheet.scss` 转换后仍写回该文件,不会改到别的目录或改名。
- **`--mirror`**:不写源码;结果写到 **`scale-verify/<与 src/packages 相同的相对路径>`**(例如 `scale-verify/actionsheet/actionsheet.scss`),便于 diff。

之后是否再跑 `pnpm run build`、是否用别的工具核对,由你自行决定;本 skill **不要求** build。

## 覆盖原 SCSS(推荐)

在 **nutui-react 仓库根目录** 执行。**务必先 commit / stash**,用完 `git restore src/packages` 或 `git checkout -- src/packages` 恢复。

若只需还原 **`src/packages/<组件>/demo.scss`**(当前脚本已跳过;若曾被旧版本误改):

```bash
find src/packages -name 'demo.scss' -exec git restore -- {} \;
```

**然后**在仓库根执行验证:

```bash
pnpm run verify-scale
```

等价:

```bash
node .cursor/skills/nutui-build-local-verify/scripts/verify-scale-generation.mjs
```

(`--in-place` / `-i` 与默认等价。)

## 报告

路径:**`scale-verify/report.json`**。覆盖模式下看 `overwriteSource === true`、`changedFileCount`、`changedFiles`。

## 其它命令

```bash
# 删除仓库根下 scale-verify/ 整目录(含 report;不还原已覆盖的 src/packages)
node .cursor/skills/nutui-build-local-verify/scripts/verify-scale-generation.mjs --clean
```

**可选**(只镜像、不覆盖源码):

```bash
pnpm run verify-scale:mirror
```

`--mirror` 与 `--in-place` 不能同时使用。

## 核对清单

- [ ] 覆盖前已 git 可回滚
- [ ] `changedFiles` 抽样无 `scale-px(0px)`、无重复嵌套 `scale-px`
- [ ] `font-size` / `font` 未被误改(转换器会跳过)

## 给用户的一句话结论

- 脚本跑完 + `changedFileCount` + 列 2~3 个 `changedFiles`
- **覆盖的是真实源码**时,验证完用 **git 恢复**
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* 本地验证:默认就地写回 src/packages 下同一路径的组件 .scss(如 …/actionsheet/actionsheet.scss)。
* 跳过 src/packages/**/demo.scss、demos、测试与快照(与 build.mjs ignore 一致)。
* --mirror 只写 scale-verify/;不包含 build;自行 git diff / 恢复即可。
*/
import fs from 'node:fs/promises'
import path from 'path'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const transform = require(path.resolve(process.cwd(), 'scripts/px-to-scale-px-in-component-scss.cjs'))

const repoRoot = process.cwd()
const packagesRoot = path.resolve(repoRoot, 'src/packages')
const outRoot = path.resolve(repoRoot, 'scale-verify')
const reportPath = path.resolve(outRoot, 'report.json')

const argv = new Set(process.argv.slice(2))
const shouldClean = argv.has('--clean')
const mirrorMode = argv.has('--mirror')
/** 默认覆盖 src/packages 原 .scss;传 --mirror 则只写 scale-verify/ */
const inPlace = !mirrorMode

if (mirrorMode && (argv.has('--in-place') || argv.has('-i'))) {
console.error('[scale-verify] 不能同时使用 --mirror 与 --in-place / -i')
process.exit(1)
}

function isScssFile(name) {
return name.endsWith('.scss')
}

function shouldSkip(relPath) {
const p = relPath.replaceAll('\\', '/')
// 与 build.mjs 的 ignore 一致:**/demo.scss 不参与 px→scale 写回
if (path.posix.basename(p) === 'demo.scss') return true
if (p.includes('/demo/')) return true
if (p.includes('/demos/')) return true
if (p.includes('/__test__/')) return true
if (p.includes('/__tests__/')) return true
if (p.includes('/__snapshots__/')) return true
if (p.startsWith('.scale-verify/')) return true
return false
}

async function walkScssFiles(dir, base = dir, list = []) {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const abs = path.resolve(dir, entry.name)
const rel = path.relative(base, abs)
if (entry.isDirectory()) {
await walkScssFiles(abs, base, list)
continue
}
if (!entry.isFile() || !isScssFile(entry.name)) continue
if (shouldSkip(rel)) continue
list.push(abs)
}
return list
}

async function ensureReportDir() {
await fs.mkdir(outRoot, { recursive: true })
}

async function prepareOutputLayout() {
if (shouldClean) {
await fs.rm(outRoot, { recursive: true, force: true })
console.log('[scale-verify] cleaned:', path.relative(repoRoot, outRoot))
return
}

await fs.rm(outRoot, { recursive: true, force: true })
await fs.mkdir(outRoot, { recursive: true })
}

async function main() {
await prepareOutputLayout()
if (shouldClean) {
return
}

const files = await walkScssFiles(packagesRoot)
files.sort()

const changed = []
for (const absFile of files) {
const rel = path.relative(packagesRoot, absFile)
const source = await fs.readFile(absFile, 'utf8')
const transformed = transform(source)
if (source === transformed) continue

const targetFile = inPlace ? absFile : path.resolve(outRoot, rel)
if (!inPlace) {
await fs.mkdir(path.dirname(targetFile), { recursive: true })
}
await fs.writeFile(targetFile, transformed, 'utf8')
changed.push(rel.replaceAll('\\', '/'))
}

await ensureReportDir()
const scssWriteRoot = inPlace
? path.relative(repoRoot, packagesRoot).replaceAll('\\', '/')
: path.relative(repoRoot, outRoot).replaceAll('\\', '/')

const report = {
generatedAt: new Date().toISOString(),
mode: inPlace ? 'in-place' : 'mirror',
overwriteSource: inPlace,
/** 本次写入的 SCSS 根路径:原地为 src/packages,镜像为仓库根下 scale-verify */
scssWriteRoot,
/** 镜像模式下的实验目录;原地模式为 null */
outDir: inPlace ? null : path.relative(repoRoot, outRoot).replaceAll('\\', '/'),
reportPath: path.relative(repoRoot, reportPath).replaceAll('\\', '/'),
totalScssFiles: files.length,
changedFileCount: changed.length,
changedFiles: changed,
}
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')

console.log('[scale-verify] mode:', report.mode)
if (!inPlace) {
console.log('[scale-verify] outDir:', report.outDir)
} else {
console.log('[scale-verify] wrote into:', path.relative(repoRoot, packagesRoot))
}
console.log('[scale-verify] totalScssFiles:', report.totalScssFiles)
console.log('[scale-verify] changedFileCount:', report.changedFileCount)
console.log('[scale-verify] report:', path.relative(repoRoot, reportPath))
}

main().catch((err) => {
console.error('[scale-verify] failed:', err)
process.exitCode = 1
})
31 changes: 29 additions & 2 deletions .cursor/skills/nutui-proportional-scaling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ description: >-
NutUI React proportional scaling on branch feat_resize: runtime --nut-scale-f /
--nut-scale-font / --nut-scale-icon from scale-f.ts (H5) and scale-f.taro.ts
(Taro), Sass helpers scale-px / scale-font-px / scale-icon-px and theme font
tokens in variables.scss & theme-*.scss; profiles standard / large / elderly;
tokens in variables.scss & theme-*.scss; npm run build / build:taro run
scripts/px-to-scale-px-in-component-scss.cjs on component SCSS in memory; profiles standard / large / elderly;
commit-backed rules e.g. never scale 0px. Use when implementing 多尺寸适配,
等比适配, 大字版, 老年版, scale-px, viewport or native bridge scaling, or
editing component SCSS for resize.
editing component SCSS for resize; SCSS: prefer calc($token + Npx) over
#{} in calc, use outer calc() when mixing tokens that compile to
var(--nutui-*).
---

# NutUI React 等比适配
Expand Down Expand Up @@ -48,6 +51,12 @@ description: >-

**主题字号档**(`theme-default.scss` / `theme-dark.scss`):`--nutui-font-size-*` 使用 `calc(Npx * var(--nut-scale-font, var(--nut-scale-f, 1)))`,与 **大字/老年** 档位对齐。

### 2.1 `npm run build` / `npm run build:taro` 时的 px → `scale-px`

- 与 `package.json` 中顺序一致:先跑 **`scripts/replace-css-var.js`**,再 **`scripts/build.mjs`** 或 **`scripts/build-taro.mjs`**;上述脚本在读取 **`src/packages/**/\*.scss`(不含 demo)** 后,会经 **`scripts/px-to-scale-px-in-component-scss.cjs`** 在**内存**里把声明值中的裸 **`Npx`** 转为 **`scale-px(Npx)`**(规则见 §3),**不写回\*\*仓库里的组件 SCSS。
- 源码里可继续手写 **`scale-px` / `scale-font-px` / `scale-icon-px`**;构建不会重复嵌套 `scale-px`。
- 该脚本对 **`calc(...)` 体内同时含 `$` 与 `/`** 的整段先做占位再替换裸 `px`,避免 postcss-scss 把 **`calc($var / 2)`** 等拆坏;其它 `calc` 内的裸 `Npx` 仍会按规则转为 `scale-px`。

---

## 3. 提交里固化的规范(务必遵守)
Expand All @@ -70,6 +79,21 @@ description: >-
- 图标占位:**`scale-icon-px`** 或已有 `--nut-icon-*`。
- 保持与 **无障碍/大屏** 相关提交协同:同一文件改尺度时,勿回退 `dialog` 等对大字兼容的改动。

### 3.5 组件 `.tsx` 图标尺寸治理(props → class → 变量)

- 对 `@nutui/icons-react` / `@nutui/icons-react-taro`:尽量避免在组件上写死 `size={12}`、`width={16}`、`height={16}`。
- **推荐模式**:在 `.tsx` 里只加语义化 `className`,到对应 `.scss` 里用变量控制尺寸(优先 `$icon-size-*` 阶梯,或组件专用变量)。
- 若是内联 `<svg>`(非 NutUI 图标组件)也遵循同一规则:移除 `width/height` 字面量,改为 class,并在 SCSS 用变量(可新增如 `$xxx-icon-size`,默认 `scale-icon-px(Npx)`)。
- 新增尺寸档优先沉淀到 `variables.scss`(如 `$icon-size-11`、`$icon-size-16`),避免同一像素值在多个组件重复散落。

### 3.4 `calc()`、Sass 变量与 `#{}`(与 `variables.scss` / 主题 token 一致)

- **推荐**:在 `calc()` 里直接写 Sass 变量,如 **`calc($steps-vertical-head-icon-size + 1px)`**、**`calc($rate-item-margin / 2)`**,而不是 **`calc(#{$steps-vertical-head-icon-size} + 1px)`**。`#{}` 只在需要把值**硬插成无引号 CSS 片段**、或要避免 Sass 对单位做提前合并时再考虑;普通设计 token 用 **`$var` 作为 `calc` 的操作数**即可。
- **与 `var(--nutui-*)` 一起运算时**:许多 token 会展开为 **`var(--nutui-…, calc(Npx * var(--nut-scale-f, 1)))`**。此时**不要**指望纯 Sass 括号在声明值里做「减法 + 固定 px」,例如
**`margin: 0 ($switch-height - $switch-border-width + 3px) 0 7px`**
会在编译结果里拼成 **`var(...)var(...)`** 一类**缺少运算符**的非法片段。应写成 **`margin: 0 calc($switch-height - $switch-border-width + 3px) 0 7px`**,让整条长度在**一个** CSS `calc()` 里由浏览器解析。
- **`100%` 与长度相减**:用 **`calc(100% - Npx)`** 一层即可,避免出现 **`100% - calc(...)`** 这类单位不合法的组合(历史上有过 postcss / 手工替换导致的损坏,以当前组件 SCSS 为准)。

---

## 4. 业务接入清单
Expand All @@ -85,6 +109,9 @@ description: >-

- [ ] 新增 **0** 尺寸未误用 `scale-px(0px)`。
- [ ] 字体/图标是否应走 **`scale-font-px` / `scale-icon-px`** 而非误用 `scale-px`。
- [ ] 含 **`$token` 与裸 `px` 的混合运算**:若 token 会变成 **`var(--nutui-*)`**,是否已用 **`calc($a - $b + Npx)`**,而不是 **`($a - $b + Npx)`** 写在 `margin` / `width` 等非纯编译期长度位置。
- [ ] `calc()` 内对设计 token 是否优先 **`calc($var + 1px)`**,避免无必要的 **`#{}`**。
- [ ] 组件 `.tsx` 是否仍有写死图标尺寸(`size/width/height`);如有,是否已改为 **class + SCSS 变量**(内联 `svg` 同理)。
- [ ] TS 侧改 `scale-f*` 已同步考虑 **Taro** 文件。
- [ ] 修改 `formatScaleValue` / 断点时,已通读 **视口与平板常量** 是否仍与文档、设计一致。

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@
"e2e:run:taro": "start-server-and-test dev:taro:h5 http://localhost:10086 cypress:run:taro",
"e2e:open:taro": "start-server-and-test dev:taro:h5 http://localhost:10086 cypress:open:taro",
"update:taro:entry": "node ./scripts/harmony/update-taro-entry",
"upgradeTaro": "pnpm --dir ./packages/nutui-taro-demo upgradeTaro"
"upgradeTaro": "pnpm --dir ./packages/nutui-taro-demo upgradeTaro",
"verify-scale": "node .cursor/skills/nutui-build-local-verify/scripts/verify-scale-generation.mjs",
"verify-scale:mirror": "node .cursor/skills/nutui-build-local-verify/scripts/verify-scale-generation.mjs --mirror"
},
"lint-staged": {
"*.{scss,md}": "prettier --write",
Expand Down
9 changes: 7 additions & 2 deletions scripts/build-taro.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import scss from 'postcss-scss'
import { copy } from 'fs-extra'
import { deleteAsync } from 'del'
import { fileURLToPath } from 'url'
import { createRequire } from 'node:module'
import { execSync } from 'child_process'
import { access, mkdir, readFile, writeFile } from 'fs/promises'
import { basename, dirname, extname, join, relative, resolve } from 'path'
Expand All @@ -18,6 +19,8 @@ import { generate } from './build-theme-typings.mjs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const require = createRequire(import.meta.url)
const pxToScalePxInComponentScss = require('./px-to-scale-px-in-component-scss.cjs')
const dist = 'release/taro/dist'
const filePath = resolve(__dirname, '../package.json')
const packageJson = JSON.parse(readFileSync(filePath, 'utf8'))
Expand Down Expand Up @@ -352,9 +355,10 @@ async function buildCSS(themeName = '') {
join(__dirname, `../src/styles/variables${themeName ? `-${themeName}` : ''}.scss`),
)
for (const file of componentScssFiles) {
const scssContent = await readFile(join(__dirname, '../', file), {
let scssContent = await readFile(join(__dirname, '../', file), {
encoding: 'utf8',
})
scssContent = pxToScalePxInComponentScss(scssContent)
// countup 是特例
const base = basename(file)
const loadPath = join(
Expand Down Expand Up @@ -444,9 +448,10 @@ async function buildHarmonyCSS(themeName = '') {
),
)
for (const file of componentScssFiles) {
const scssContent = await readFile(join(__dirname, '../', file), {
let scssContent = await readFile(join(__dirname, '../', file), {
encoding: 'utf8',
})
scssContent = pxToScalePxInComponentScss(scssContent)
// countup 是特例
const base = basename(file)
const loadPath = join(
Expand Down
6 changes: 5 additions & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import scss from 'postcss-scss'
import { copy } from 'fs-extra'
import { deleteAsync } from 'del'
import { fileURLToPath } from 'url'
import { createRequire } from 'node:module'
import { execSync } from 'child_process'
import { access, mkdir, readFile, writeFile } from 'fs/promises'
import { basename, dirname, extname, join, relative, resolve } from 'path'
Expand All @@ -18,6 +19,8 @@ import { generate } from './build-theme-typings.mjs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const require = createRequire(import.meta.url)
const pxToScalePxInComponentScss = require('./px-to-scale-px-in-component-scss.cjs')
const dist = 'release/h5/dist'
const filePath = resolve(__dirname, '../package.json')
const packageJson = JSON.parse(readFileSync(filePath, 'utf8'))
Expand Down Expand Up @@ -301,9 +304,10 @@ async function buildCSS(themeName = '') {
join(__dirname, `../src/styles/variables${themeName ? `-${themeName}` : ''}.scss`),
)
for (const file of componentScssFiles) {
const scssContent = await readFile(join(__dirname, '../', file), {
let scssContent = await readFile(join(__dirname, '../', file), {
encoding: 'utf8',
})
scssContent = pxToScalePxInComponentScss(scssContent)
// countup 是特例
const base = basename(file)
const loadPath = join(
Expand Down
3 changes: 3 additions & 0 deletions scripts/generate-css-for-rtl-comparison.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const variables = fs.readFileSync(
path.join(__dirname, '../src/styles/variables.scss')
)

const pxToScalePxInComponentScss = require('./px-to-scale-px-in-component-scss.cjs')

function postcssRemoveRtl() {
return {
postcssPlugin: 'postcss-remove-rtl',
Expand Down Expand Up @@ -50,6 +52,7 @@ components.forEach((component) => {
)
)
.toString()
content = pxToScalePxInComponentScss(content)
let to = path.join(
__dirname,
`../src/packages/${componentName}/${componentName}.rtl.css`
Expand Down
Loading
Loading