Browse Source

style(ui): 引导页 8px 收敛与上批 gap 漏网修正,新增间距核对门槛

4.18 的核对脚本把 components/onboarding/ 整目录排除了,于是规则里最明确的
「整个项目所有卡片的圆角都必须是 8px」在 26 个引导页上从未被核对过。排除是
脚本作者自己加的,不是用户说的。本批先把它从「待定夺」变成「有数据」。

实测引导页 42 处 borderRadius:真卡片只有 5 张,其余是控件、图标底色块、圆与
占位框。「引导页自成一套半径体系」这个排除前提是假的 —— 其卡片半径是
8/14/10/8/8,只有两张不在 8,不存在另一套体系。

改动(10 个文件 +14/−14):
- ScreenShell.scrollContent 内容留白 20 → 8;P1Login.content 24 → 8
  (P1 不用页壳,自带 content)
- RadioRow.row 12/14/16 → 8/8/8 —— 它与主应用 holland/question.option、
  game/level.option 同形态(横排 + space-between + 白底 + borderWidth: 1),
  那两处上批已判为卡片并置 8,是代码库自己否掉了「RadioRow 属控件」的判断
- B1Wechat.preview 圆角 14 → 8、内边距 14 → 8
- P12Company.notice 圆角 10 → 8、内边距 12 → 8、卡片间隔 16 → 8
- P9JobStatus.list / P18Education.list 的卡片间隔 gap 10 → 8

改错又还原的两处(本批最该记的教训):
ScreenShell.backRow 与 footer 我改成了 8,两处都还原为 16。主应用底部固定区
的口径就是 16(padding: 16 出现 4 次),头部返回图标的视觉位置也在 16 上下
(headerBtn 是 40×40 居中盒,42/58 个文件如此)。即引导页原本的 16 本就与
主应用一致,只有内容留白 20 与主应用的 8 不符。我先前「同屏三个留白、体系
不自洽」的判断站不住:16 是全应用通用的框架留白,且返回按钮属页面框架、
不是卡片,不在「页面留白」范围内。教训是:判断某个值该不该统一之前,
先量一遍主应用同类元素的既有取值。

补上 4.18 卡片间隔维度的 3 处漏网:holland/question.options gap 10、
holland.featureList gap 10、jobs/weight-settings.templateRow gap 10,
三者子元素分别是 option / featureCard / templateCard,全是 r8/padding 8 的卡片。

纠正 4.18 的不实结论:当时报「卡片 477 / 圆角 = 8:217」并称剩余非 8 的只有
若干控件。实际那个脚本的排除正则锚定全名(^(...|btn|tag|chip|...)$),
retryBtn / statusTag / filterChip / progressTrack 这类带后缀的键一个都没匹配上,
260 个控件全被当成卡片报出来。217/477 是脚本的原始通过数,剩余 260 项是肉眼
判成控件的,脚本从未验证过这个判断。另有大小写漏匹配与 *Box 排除过头
(emptyBox / salaryBox / matchBox 被一并吞掉,等于把上批改过的东西排除在核对之外)。

新增 scripts/check-spacing.mjs + npm run check:spacing(--error 有命中即 exit 1),
与 check-routes / check-affordances / check-icons / check-backend-stubs 同族体例。
把判据编码进脚本而不是留在人脑:结构判定而非命名判定、按词尾而非全名排除、
圆用几何判据(width === height 且 borderRadius ≈ width/2)、排除项逐条打印供复核,
给排除表加名字要写理由。可失败性已在仓库脚本上复验:塞入含 4 类违规的探针文件
四维全命中且 exit 1,删掉后 exit 0。

真机验证(emulator-5554,非代码走查):引导页由内部状态机驱动、无 URL 参数,
临时改初始步截图,看完已还原并 git diff 确认无残留。用像素测量代替目视
(/tmp/mdj/px.js 解码 PNG 取某行首个非背景像素;密度 420dpi → 比例 2.625)。
P1 按钮 8dp、P9 卡片 8dp/按钮 8dp、P17 卡片 8dp/按钮 8dp、B1 卡片居中(设计如此)
/按钮 8dp、P12 提示卡 8dp/按钮 8dp。头部返回箭头改动前后同行 52px → 73px,
差 21px 正好 8dp,一次同时证明热重载与还原都生效。

门槛:tsc 0 / eslint 0 / 26 套件 365 用例全过 / check-routes /
check:affordances / check:icons(194 文件 1357 字形)/ check-backend-stubs(140 函数)/
check:spacing(卡片 196,圆角 = 8:196/196,另三维度 0)全绿。
master
doublex001 1 month ago
parent
commit
eaa8adb858
  1. 2
      app/holland.tsx
  2. 2
      app/holland/question.tsx
  3. 2
      app/jobs/weight-settings.tsx
  4. 2
      components/onboarding/screens/B1Wechat.tsx
  5. 6
      components/onboarding/screens/P12Company.tsx
  6. 2
      components/onboarding/screens/P18Education.tsx
  7. 2
      components/onboarding/screens/P1Login.tsx
  8. 2
      components/onboarding/screens/P9JobStatus.tsx
  9. 6
      components/onboarding/ui/RadioRow.tsx
  10. 2
      components/onboarding/ui/ScreenShell.tsx
  11. 3
      package.json
  12. 239
      scripts/check-spacing.mjs
  13. 125
      项目开发状态.md

2
app/holland.tsx

@ -257,7 +257,7 @@ const styles = StyleSheet.create({
lineHeight: 16, lineHeight: 16,
}, },
featureList: { featureList: {
gap: 10, gap: 8,
marginBottom: 20, marginBottom: 20,
}, },
featureCard: { featureCard: {

2
app/holland/question.tsx

@ -259,7 +259,7 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
lineHeight: 28, lineHeight: 28,
}, },
options: { gap: 10, marginBottom: 20 }, options: { gap: 8, marginBottom: 20 },
option: { option: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

2
app/jobs/weight-settings.tsx

@ -215,7 +215,7 @@ const styles = StyleSheet.create({
marginRight: 8, marginRight: 8,
}, },
sectionTitle: { fontSize: 15, fontWeight: '500', color: colors.textPrimary }, sectionTitle: { fontSize: 15, fontWeight: '500', color: colors.textPrimary },
templateRow: { gap: 10, paddingRight: 16 }, templateRow: { gap: 8, paddingRight: 16 },
templateCard: { templateCard: {
width: 140, width: 140,
padding: 8, padding: 8,

2
components/onboarding/screens/B1Wechat.tsx

@ -32,7 +32,7 @@ export function B1Wechat() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 18, paddingBottom: 40 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 18, paddingBottom: 40 },
preview: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.white, padding: 14, borderRadius: 14, borderWidth: 1, borderColor: colors.border }, preview: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.white, padding: 8, borderRadius: 8, borderWidth: 1, borderColor: colors.border },
previewText: { gap: 4 }, previewText: { gap: 4 },
nick: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, nick: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
tip: { fontSize: 12, color: colors.textHint }, tip: { fontSize: 12, color: colors.textHint },

6
components/onboarding/screens/P12Company.tsx

@ -33,9 +33,9 @@ export function P12Company() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
notice: { notice: {
marginTop: 16, marginTop: 8,
padding: 12, padding: 8,
borderRadius: 10, borderRadius: 8,
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
}, },
noticeText: { fontSize: 12, color: colors.primaryText, lineHeight: 18 }, noticeText: { fontSize: 12, color: colors.primaryText, lineHeight: 18 },

2
components/onboarding/screens/P18Education.tsx

@ -50,7 +50,7 @@ const styles = StyleSheet.create({
alignSelf: 'center', alignSelf: 'center',
marginBottom: 18, marginBottom: 18,
}, },
list: { gap: 10 }, list: { gap: 8 },
subTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, subTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 },
footer: { marginTop: 28 }, footer: { marginTop: 28 },
}); });

2
components/onboarding/screens/P1Login.tsx

@ -76,7 +76,7 @@ export function P1Login() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.pageBg }, screen: { flex: 1, backgroundColor: colors.pageBg },
topGlow: { position: 'absolute', top: 0, left: 0, right: 0, height: 200 }, topGlow: { position: 'absolute', top: 0, left: 0, right: 0, height: 200 },
content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 }, content: { flexGrow: 1, paddingHorizontal: 8, paddingBottom: 12 },
brandArea: { alignItems: 'center', marginTop: '22%' }, brandArea: { alignItems: 'center', marginTop: '22%' },
flexSpacer: { flex: 1, minHeight: 28 }, flexSpacer: { flex: 1, minHeight: 28 },
quickBtn: { quickBtn: {

2
components/onboarding/screens/P9JobStatus.tsx

@ -59,7 +59,7 @@ const styles = StyleSheet.create({
alignSelf: 'center', alignSelf: 'center',
marginBottom: 18, marginBottom: 18,
}, },
list: { gap: 10 }, list: { gap: 8 },
note: { fontSize: 12, color: colors.textHint, marginTop: 14, textAlign: 'center' }, note: { fontSize: 12, color: colors.textHint, marginTop: 14, textAlign: 'center' },
agree: { marginTop: 18 }, agree: { marginTop: 18 },
agreeText: { fontSize: 13, color: colors.textSecondary }, agreeText: { fontSize: 13, color: colors.textSecondary },

6
components/onboarding/ui/RadioRow.tsx

@ -29,9 +29,9 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 14, paddingVertical: 8,
paddingHorizontal: 16, paddingHorizontal: 8,
borderRadius: 12, borderRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
backgroundColor: colors.white, backgroundColor: colors.white,

2
components/onboarding/ui/ScreenShell.tsx

@ -47,6 +47,6 @@ const styles = StyleSheet.create({
titleInline: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, titleInline: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
titleCenter: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', paddingVertical: 12 }, titleCenter: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', paddingVertical: 12 },
scroll: { flex: 1 }, scroll: { flex: 1 },
scrollContent: { flexGrow: 1, padding: 20, paddingBottom: 28 }, scrollContent: { flexGrow: 1, padding: 8, paddingBottom: 28 },
footer: { padding: 16, paddingBottom: 22, backgroundColor: colors.pageBg }, footer: { padding: 16, paddingBottom: 22, backgroundColor: colors.pageBg },
}); });

3
package.json

@ -36,7 +36,8 @@
"check:affordances": "node scripts/check-affordances.mjs --error", "check:affordances": "node scripts/check-affordances.mjs --error",
"check:affordances:pending": "node scripts/check-affordances.mjs --pending", "check:affordances:pending": "node scripts/check-affordances.mjs --pending",
"check:backend-stubs": "node scripts/check-backend-stubs.mjs", "check:backend-stubs": "node scripts/check-backend-stubs.mjs",
"check:icons": "node scripts/check-icons.mjs --error" "check:icons": "node scripts/check-icons.mjs --error",
"check:spacing": "node scripts/check-spacing.mjs --error"
}, },
"private": true, "private": true,
"devDependencies": { "devDependencies": {

239
scripts/check-spacing.mjs

@ -0,0 +1,239 @@
/**
* 间距与圆角校验:核对 `AGENTS.md` 的
* 「整个项目所有卡片的圆角都必须是8px,无阴影,边距间隔一致,为8px。」
*
* 背景(2026-09-18 ~ 09-20 的真实教训):
* 这条规则先后做过两批(4.17 圆角 / 4.18 边距),两批**都报过不实的结论**:
*
* ① 4.17 用「命名判定」(键名含 card)找卡片 → **漏网**。`pendingItem` / `dimItem` /
* `suggestionRow` / `matchBanner` / `salaryBox` / `taxItem` 这些名字里没有 card、
* 结构上却是卡片的块,一个都没被查到。改成结构判定后才浮出来。
* ② 4.18 我报「卡片 477 / 圆角 = 8:217」,并说「剩余非 8 的只有若干控件」。
* 实际上那个脚本的排除正则是 `^(...|btn|tag|chip|...)$` —— **锚定全名**,
* 于是 `retryBtn` / `statusTag` / `filterChip` / `progressTrack` 这类
* **带后缀的键一个都匹配不上**,260 个控件全被当成卡片报出来。
* 「217/477」是脚本的原始通过数,剩下 260 项是我**肉眼判成控件**的,
* **脚本从未验证过这个判断**。这就是「推断冒充验证」。
*
* 所以本脚本把判据**编码进去**,而不是留在人脑里:
*
* 卡片 = 有 borderRadius 且(有 backgroundColor 或 borderWidth),且不属于下列形态
*
* ① 圆:`width === height` 且 `borderRadius ≈ width / 2`。
* 圆不是卡片 —— 把 120×120 / r60 的装饰圆改成 8 会把它变成圆角方块,属误改。
* 用**几何判据**而不是名字清单,是为了以后新增的圆自动被认出来。
* ② 控件 / 标签 / 按钮 / 图标底:按**词尾**判定(不是全名!见上文缺陷)。
* 排除项一律**逐条打印供复核**,不静默吞掉 —— 否则排除表本身会变成新的黑箱。
*
* **不排除**:`option` / `row` / `card` / `preview` / `notice` / `taxItem` / `dimItem` / 任何 `*Item`。
* 它们本身就是卡片,必须参与核对。(`RadioRow.row`、`holland/question.option` 同形态,
* 都是卡片;把 RadioRow 归成「控件排除」是本脚本要防的那类错误。)
*
* 三个维度(对应规则里的「边距间隔一致」):
* 页面留白 —— 主 ScrollView 的 contentContainerStyle 的 padding / paddingHorizontal
* 卡片内边距 —— 卡片的 padding(无 padding 时看 paddingHorizontal / paddingVertical)
* 卡片间隔 —— 卡片的 marginTop / marginBottom
*
* **不在范围内**(规则主语是「所有卡片」,用户确认的「页面留白」问的是「卡片离屏幕边缘的距离」):
* - 卡片**内部**子元素之间的间距(如卡内若干行文字的 gap)
* - 页面框架(头部返回箭头 backRow / 底部固定区 footer)—— 主应用同类取值为 16,非 8
* - 控件自身的 padding(输入框、chip、标签)
*
* 可失败性:本脚本已用塞了 4 类违规(r=12 的卡 / padding 12 的卡 / marginTop 12 的卡 /
* 留白 16 的页)的临时文件验证过,四个维度全部命中。**「全绿」必须配上这一步,
* 否则分不清是合规还是脚本坏了。**
*
* 用法:node scripts/check-spacing.mjs # 列出全部 + 排除项
* node scripts/check-spacing.mjs --error # 有命中则 exit 1(可用于 CI)
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const STRICT = process.argv.includes('--error');
const ROOTS = ['app', 'components', 'src'];
const TARGET = 8;
/** 要核对的间距维度 */
const CARD_GAP_PROPS = ['marginTop', 'marginBottom'];
/**
* 控件 / 标签 的词尾判据。
* **必须按词尾匹配**:锚定全名会漏掉 `retryBtn` / `statusTag` / `filterChip` 这类带后缀的键
* (4.18 的原始缺陷)。大小写不敏感:RN 里 `tag` / `input` / `chip` 大量是小写键。
*/
const CONTROL_SUFFIX =
/(Btn|Button|Tag|Chip|Badge|Input|Indicator|TextArea|Upload|Checkbox|Puzzle|Wrap|Tab|Track|Fill|Bar|Dot|Circle|Knob|Slider|Radio|Thumb|Handle|Logo|Icon|Toast|Cancel|Confirm|Pie|Line|opt|placeholder|paper|slot|block|progress|counter|glow|Submit)$/i;
/**
* 具名例外(卡片形态的除外项,逐个看过,不是「差不多」)。
* 加这一条要写理由 —— 名单是判据的一部分,不是把输出压到 0 的手段。
*/
const EXPLICIT_CONTROL = new Set([
'filterItem', // skill-cloud 行业筛选胶囊
'emptyTabItem', // job-evaluations 空态里的 Tab 项
'tipAction', // jobs/rank 的「查看全部」文字链
'templateLineShort', // resume/export 模板缩略图里的模拟文字行
'box', // onboarding/SearchInput、AgreementCheckbox 的控件盒
'inputBox', // salary-calculator 输入位
'searchBox', // jobs 搜索框
'logoBox', // enterprise-overview Logo 底
'avatarBox', // candidate-detail 头像位
'uploadBox', // appeal 上传位
'puzzleArea', // SliderCaptcha 拼图区
]);
/* ---------------- 解析 ---------------- */
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.tsx$/.test(entry)) out.push(full.split(/[\\/]/).join('/'));
}
return out;
}
/** 取 StyleSheet.create 里的顶层样式块(两空格缩进),单行块与多行块都要支持 */
function styleBlocks(src) {
const out = [];
const lines = src.split('\n');
for (let i = 0; i < lines.length; i += 1) {
const head = lines[i].match(/^(\s{2})([A-Za-z_$][\w$]*)\s*:\s*\{/);
if (!head) continue;
const body = [lines[i]];
if (!/\}\s*,?\s*$/.test(lines[i])) {
for (let j = i + 1; j < lines.length; j += 1) {
if (new RegExp(`^${head[1]}\\}`).test(lines[j])) break;
body.push(lines[j]);
}
}
out.push({ key: head[2], text: body.join('\n') });
}
return out;
}
/**
* 单行样式块 `a: { gap: 12 },` 会让 `^\s*prop:` 这类行首锚点全部失效,
* 所以先把 `{ } ,` 换成换行再匹配。
*/
const flatten = (text) => text.replace(/[{},]/g, '\n');
function readNumber(text, prop) {
const m = flatten(text).match(new RegExp(`^[ \\t]*${prop}:[ \\t]*(-?[0-9.]+)`, 'm'));
return m ? Number(m[1]) : null;
}
const hasProp = (text, prop) => new RegExp(`^[ \\t]*${prop}:`, 'm').test(flatten(text));
const isCircle = ({ text }) => {
const w = readNumber(text, 'width');
const h = readNumber(text, 'height');
const r = readNumber(text, 'borderRadius');
return w !== null && h !== null && w === h && Math.abs(r - w / 2) < 0.51;
};
/* ---------------- 核对 ---------------- */
const files = ROOTS.flatMap((root) => walk(root));
const violations = { radius: [], padding: [], gap: [], gutter: [] };
const excluded = [];
let cardCount = 0;
let radiusOk = 0;
for (const file of files) {
const src = readFileSync(file, 'utf8');
if (!src.includes('StyleSheet.create(')) continue;
for (const block of styleBlocks(src)) {
if (readNumber(block.text, 'borderRadius') === null) continue;
if (!hasProp(block.text, 'backgroundColor') && !hasProp(block.text, 'borderWidth')) continue;
if (isCircle(block)) {
excluded.push(`${file} | ${block.key} | 圆 ${readNumber(block.text, 'width')}`);
continue;
}
if (CONTROL_SUFFIX.test(block.key) || EXPLICIT_CONTROL.has(block.key)) {
excluded.push(`${file} | ${block.key} | 控件/标签`);
continue;
}
cardCount += 1;
const radius = readNumber(block.text, 'borderRadius');
if (radius !== TARGET) {
violations.radius.push(`${file} | ${block.key} | borderRadius ${radius}`);
continue;
}
radiusOk += 1;
const padding = readNumber(block.text, 'padding');
if (padding !== null) {
if (padding !== TARGET) violations.padding.push(`${file} | ${block.key} | padding ${padding}`);
} else {
for (const prop of ['paddingHorizontal', 'paddingVertical']) {
const value = readNumber(block.text, prop);
if (value !== null && value !== TARGET) {
violations.padding.push(`${file} | ${block.key} | ${prop} ${value}`);
}
}
}
for (const prop of CARD_GAP_PROPS) {
const value = readNumber(block.text, prop);
if (value !== null && value !== TARGET) {
violations.gap.push(`${file} | ${block.key} | ${prop} ${value}`);
}
}
}
}
/**
* 页面留白:取主内容 ScrollView。
* 一页可能有多个 ScrollView(空态 / 错误态 + 主内容),**主内容在后面**,故取最后一个匹配;
* 没有 contentContainerStyle 的文件(纯 View 布局)用候选键名兜底。
*/
const GUTTER_FALLBACK_KEYS = ['content', 'body', 'scrollContent', 'listContent', 'group', 'container'];
for (const file of files) {
const src = readFileSync(file, 'utf8');
if (!src.includes('contentContainerStyle')) continue;
const referenced = [...src.matchAll(/contentContainerStyle=\{styles\.([\w$]+)/g)].map((m) => m[1]);
const candidates = referenced.length ? [referenced[referenced.length - 1]] : [];
for (const key of GUTTER_FALLBACK_KEYS) if (!candidates.includes(key)) candidates.push(key);
const blocks = styleBlocks(src);
for (const key of candidates) {
const block = blocks.find((b) => b.key === key);
if (!block) continue;
const padding = readNumber(block.text, 'padding');
const horizontal = readNumber(block.text, 'paddingHorizontal');
if (padding === null && horizontal === null) continue;
const value = padding !== null ? padding : horizontal;
if (value !== TARGET) violations.gutter.push(`${file} | ${key} | ${value}`);
break;
}
}
/* ---------------- 输出 ---------------- */
const total = violations.radius.length + violations.padding.length
+ violations.gap.length + violations.gutter.length;
console.log(`卡片(结构判定):${cardCount},圆角 = ${TARGET}:${radiusOk} / ${cardCount}`);
console.log(` 圆角 ≠ ${TARGET}:${violations.radius.length}`);
violations.radius.forEach((line) => console.log(` ${line}`));
console.log(` 内边距 ≠ ${TARGET}:${violations.padding.length}`);
violations.padding.forEach((line) => console.log(` ${line}`));
console.log(` 卡片间隔 ≠ ${TARGET}:${violations.gap.length}`);
violations.gap.forEach((line) => console.log(` ${line}`));
console.log(` 页面留白 ≠ ${TARGET}:${violations.gutter.length}`);
violations.gutter.forEach((line) => console.log(` ${line}`));
console.log(`--- 已剔除 ${excluded.length} 项(圆 / 控件),逐条列出供复核 ---`);
excluded.forEach((line) => console.log(` ${line}`));
if (STRICT && total > 0) {
console.error(`\n❌ 发现 ${total} 处间距/圆角不符合 AGENTS.md 的 ${TARGET}px 规则`);
process.exit(1);
}
if (total === 0) console.log(`\n✅ 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白 全部为 ${TARGET}px`);

125
项目开发状态.md

@ -1567,19 +1567,121 @@ modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 },
#### 11. 本批次仍未做 / 待定夺 #### 11. 本批次仍未做 / 待定夺
- **【待定夺】`ScreenShell.padding: 20 → 8` 是否执行**:影响 26 个引导页的首启视觉。 - ~~**【待定夺】`ScreenShell.padding: 20 → 8`**~~ → **已执行,但只改内容留白**:见 4.19。
规则说「整个项目」,且用户已确认「页面留白也统一 8px」;但引导页是独立设计流程, 引导页「自成一套体系」这个排除前提**是错的**(实测其卡片是 8/14/10/8/8,只有两张不在 8,
半径体系已按同一逻辑排除。**改动只有一行,等一句话。** 根本不存在另一套半径体系);而 backRow / footer 的 16 **本来就是全应用通用的框架留白**
- **【待定夺】卡片半径口径是否继续收紧**:结构判定下全仓仍有约 260 个 (主应用同类为 16),改成 8 才是错的,已还原。
「有 borderRadius + 有背景/描边」的块不是 8,逐个核过后确认**绝大多数是控件/标签/按钮/图标底** - ~~**【待定夺】卡片半径口径是否继续收紧**~~ → **已用脚本证明,不再是估计**:见 4.19 第 4 条。
(`retryBtn` / `avatar` / `tabIndicator` / `progressTrack` / `badge` / `sectionBar` …), 全仓 196 张卡片圆角 100% = 8。上一版「约 260 个不是 8」是**脚本正则锚定全名导致漏排除**
已按第 5 条排除。**剩余无争议项已清零,未发现新的漏网卡片。** (`retryBtn` / `statusTag` / `filterChip` 这类带后缀的键一个都没匹配上),
当时报的「217/477」是脚本原始通过数,**260 项是我肉眼判成控件、脚本从未验证过**。现已把判据编码进脚本。
- 4.16 第七节 A–E 五条重复声明发现、4.16 第 5/6 条两项产品定夺、4.15 起的其余开放项 - 4.16 第七节 A–E 五条重复声明发现、4.16 第 5/6 条两项产品定夺、4.15 起的其余开放项
**状态全部不变**(详见 4.17 第 10 条)。 **状态全部不变**(详见 4.17 第 10 条)。
- **仍未建立设计令牌层**:8px 与各间距值目前仍是**裸字面量**,没有单一来源、没有 lint 强制。 - **仍未建立设计令牌层**:8px 与各间距值目前仍是**裸字面量**,没有单一来源、没有 lint 强制。
这是「改一次值要动 73 个文件」的根因,也是本批次规模这么大的原因。**建议下一批做** 这是「改一次值要动 73 个文件」的根因,也是本批次规模这么大的原因。**建议下一批做**
(`src/theme` 目前只有 `colors.ts`)。 (`src/theme` 目前只有 `colors.ts`)。
### 4.19 引导页 8px 收敛 + 上批 gap 漏网 + 核对脚本自身缺陷(2026-09-20 批次,接 4.18)
**起因**:4.18 的核对脚本把 `components/onboarding/` 整目录排除了。而规则里最明确、最没有
裁量空间的一句是「整个项目**所有卡片**的圆角都必须是 8px」——**这条在 26 个引导页上从未被核对过**。
排除是我自己加的,不是用户说的。本批先把这件事从「等你定夺」变成「有数据」。
**1. 引导页实测**:42 处 `borderRadius`,逐块读过上下文后分类 —— 真卡片只有 5 张
(`B1Wechat.preview` / `P12Company.notice` / `P17Reference.card` / `P3Identity.card` / `Modal.card`),
其余 37 处是控件(`input` / `upload` / `opt` / `quickBtn` / `cardBtn` / `box` / `tag` / `chip`)、
图标底色块(5 个 `iconWrap` 的 18)、圆(`P22Done.glow` 120/60、`dot`、`Avatar`)与占位框(`slot`)。
**「引导页自成一套半径体系」这个前提是假的**:其卡片半径是 8/14/10/8/8,只有两张不在 8,
根本不存在另一套体系。排除所依据的理由本身不成立。
**2. 改了什么**
| # | 位置 | 改动 | 依据 |
|---|---|---|---|
| 1 | `ScreenShell.scrollContent` | `padding: 20 → 8` | 内容留白,规则 + 用户确认「页面留白也统一 8px」 |
| 2 | `P1Login.content` | `paddingHorizontal: 24 → 8` | 同上(P1 不用页壳,自带 content) |
| 3 | `RadioRow.row` | `12/14/16 → 8/8/8` | 与主应用 `holland/question.option`、`game/level.option` **同形态**(横排 + space-between + 白底 + borderWidth: 1),那两处上批已判为卡片并置 8 —— **是代码库自己否掉了我把 RadioRow 归成「控件排除」的判断** |
| 4 | `B1Wechat.preview` | `r14 → 8`、`padding 14 → 8` | 真卡片 |
| 5 | `P12Company.notice` | `r10 → 8`、`padding 12 → 8`、`marginTop 16 → 8` | 真卡片(同主应用 `aiTip` / `matchBanner` 口径) |
| 6 | `P9JobStatus.list` / `P18Education.list` | `gap 10 → 8` | RadioRow 卡片堆叠 |
**3. 改错了又还原的两处(本批最该记的教训)**
- `ScreenShell.backRow` 我改成了 `paddingHorizontal: 8`,`footer` 改成了 `paddingHorizontal: 8`。
- **两处都还原成 16**。理由:主应用底部固定区的口径就是 **16**(`padding: 16` 出现 4 次、
`paddingHorizontal: 16` 2 次),头部返回图标的视觉位置也在 16 上下(`headerBtn` 是 40×40 居中盒,
42/58 个文件都是这个结构)。也就是说**引导页原本的 backRow 16 / footer 16 本来就与主应用一致**,
只有内容留白 20 与主应用的 8 不符。
- 我先前那句「同一屏上 20 / 16 / 16 三个留白、体系不自洽」**站不住**:16 是全应用通用的框架留白,
不是引导页的局部不一致。而且返回按钮属于**页面框架**,不是卡片——用户确认的「页面留白」问的是
「卡片离屏幕边缘的距离」,头部框架本不在规则范围内,我改它是越界。
- **教训**:判断某个值「该不该统一」之前,先量一遍**主应用同类元素的既有取值**,不要凭「同屏三个数不一样」
就推定不一致。
**4. 核对脚本自身有三处缺陷(比代码改动更重要)**
4.18 我报的「卡片 477 / 圆角 = 8:217」是**脚本的原始通过数,不是证明**——剩余 260 项是我肉眼判成
控件/标签的,脚本从没验证过这个判断。本批把判据编码进脚本时暴露三处缺陷:
1. **排除正则锚定全名**:`^(...|btn|tag|chip|...)$` 匹配不到 `retryBtn` / `statusTag` / `filterChip`,
于是 203 个控件全被当成卡片报出来。改成按**词尾**判定。
2. **大小写**:React Native 里大量键是小写(`tag` / `input` / `chip` / `box` / `textarea`),
而列表里写的是 `Tag` / `Input`,全部漏匹配。加 `i` 标志。
3. **`Box` 排除过头**:`emptyBox`(上批刚改成 `padding: 8` 的空态盒)、`salaryBox`、`matchBox`
都被 `*Box` 一起吞掉了 —— 等于把上批改过的东西排除在核对之外。改为只排除确属控件的具名盒。
另外 `option` / `row` / `card` / `preview` / `notice` / `taxItem` / `dimItem` / `*Item` **一律不排除**,
它们本身就是卡片,必须参与核对。
- **可失败性验证(不能失败的东西不算检查)**:拿一个塞了 4 类违规(r=12 的卡、padding 12 的卡、
marginTop 12 的卡、留白 16 的页)的临时文件喂给脚本,四个维度**全部命中**。
第一次喂的时候脚本报「0 张卡片」——原因是 `/tmp/...` 在 Windows 的 node 里解析成 `C:\tmp\...`,
目录不存在,walk 直接 return。**「全绿」必须配上这一步,否则分不清是合规还是脚本坏了。**
**5. 上批(4.18)卡片间隔维度的漏网 3 处**:gap 维度当时是逐个容器看 JSX 判的,漏了
`app/holland/question.tsx options`(gap 10)、`app/holland.tsx featureList`(10)、
`app/jobs/weight-settings.tsx templateRow`(10)——三者的子元素分别是 `option` / `featureCard` /
`templateCard`,**全都是 r8 / padding 8 的卡片**。本批已全部改为 8。
- 排除确认(逐个看过,不是「差不多」):`ability.subItems`(进度条行)、`progress.monthGrid`(图表格)、
`P5City.list`(下划线分隔行,无圆角无底)、`P7Salary.col`(选项按钮列)、
`legal.*.benefits` / `resume.*.rowBody` / `compare.scoreBars` / `P17Reference.card` 的 gap
(**均为卡片内部子行间距**,不是卡片之间)、`skill-cloud.searchEmpty` / `talent-recommendation.emptyList`
(**空态盒内部**)、以及 14 个 `center` / `stateBox` / `emptyList`(错误重试态居中容器,误改会打散居中)。
**6. 真机验证(模拟器 `emulator-5554`,非代码走查)**
引导页由内部状态机驱动(`useOnboardingFlow` 的 `useState<string>('P1')`),没有 URL 参数,
deep-link 只能到 P1。**临时**把初始步改成目标页截图,**看完已还原**,并 `git diff` 确认该文件无残留改动。
- 用像素测量代替肉眼(截图缩放后目视判读不可靠):`/tmp/mdj/px.js` 解码 PNG 后找某行第一个非背景像素,
密度 420dpi → 比例 **2.625**(不是 2.75,第一版算错过)。
- 实测:P1 按钮 8dp · P9 首张 RadioRow 8dp / 底部按钮 8dp · P17 卡片 8dp / 按钮 8dp ·
B1 卡片居中(`center` 的 `alignItems: 'center'`,设计如此)/ 按钮 8dp · P12 提示卡 8dp / 按钮 8dp。
- **头部返回箭头:改动前后同一行 52px → 73px,差 21px = 正好 8dp** —— 一次同时证明了
「热重载生效」与「还原生效」。
- 截图里的灰色齿轮圆是 **Expo 开发菜单浮钮**,不是应用 UI。P12 搜索框里的 `111` 是设备本地
留存的引导数据(`profile.school` 同类),不是本批改动。
**7. 门槛**:`npx tsc --noEmit` 0 · `npx eslint .` 0 · `npm test` 26 套件 / 365 用例全过 ·
`check-routes` · `check:affordances` · `check:icons`(194 文件 / 1357 字形)· `check-backend-stubs`(140 函数)全绿。
改动量 **11 文件(含新增 `scripts/check-spacing.mjs`)+ 状态文档**。
**8. 把这份核对**落成门槛**,不再是「跑完就没了的一次性脚本**
- 新增 `scripts/check-spacing.mjs` + `npm run check:spacing`(`--error` 有命中即 exit 1),
与 `check-routes` / `check-affordances` / `check-icons` / `check-backend-stubs` 同族同体例。
- **动机**:4.17 / 4.18 两批都靠「临时写个脚本、跑完删掉」,于是这次的教训下一批就得重学一遍。
规则要能被强制,否则「改一次值动 73 个文件」会一直复发。
- 脚本里把判据**写成了注释与代码**:结构判定而非命名判定、按词尾而非全名排除、圆用几何判据、
排除项逐条打印。**给排除表加名字要写理由**——否则排除表本身会变成新的黑箱。
- **可失败性已在仓库脚本上复验**:往 `src/` 塞一个含 4 类违规(r=12 的卡 / padding 12 的卡 /
marginTop 12 的卡 / 留白 16 的页)的探针文件 → 四维全命中、**exit 1**;删掉探针 → **exit 0**。
- **终值**:卡片 196(含引导页),圆角 = 8:**196 / 196**,内边距 ≠ 8:0,卡片间隔 ≠ 8:0,
页面留白 ≠ 8:0;另剔除 359 项(圆 / 控件)并逐项列出。
**9. 仍未做 / 待定夺**
- **主应用底部按钮 16 vs 卡片 8**:14 个带 footer 的主应用页面是 `padding: 16`,同屏卡片是 8,
两者左右各差 8。这是**主应用既有的状态**,且底部按钮不属于规则里的「卡片」;要不要一起统一到 8,
**需要产品/设计一句话**(改的是 14 个主应用页面,比引导页那批影响更大,故不擅动)。
- **非卡片元素之间的间距仍不一致**:同为图标底色块,`P10FirstWork` / `P13JoinDate` 是 `marginBottom: 12`,
`P8Resume1` / `P9JobStatus` / `P18Education` 是 18。按「规则主语是所有卡片」的读法,这些不在范围内;
若要按字面「边距间隔一致」执行,则属另一批。
- **仍未建立设计令牌层**(4.18 第 9 条):8px 与各间距值仍是**裸字面量**,没有单一来源。
本批补上了**强制**(`npm run check:spacing`,见第 8 条)但没有补**来源** ——
现在「值跑偏了会被拦住」,但「改一次值仍要动 N 个文件」。根因只解决了一半,建议下一批做
(`src/theme` 目前只有 `colors.ts`;可参照 `colors.ts` 的 `semanticColorTokens` 做法建 spacing 令牌)。
--- ---
## 五、后续增量开发建议(优先级) ## 五、后续增量开发建议(优先级)
@ -1628,6 +1730,15 @@ node scripts/check-backend-stubs.mjs
# 页面用 `as keyof typeof Ionicons.glyphMap` 断言会吞掉这类类型错误,故需要此脚本 # 页面用 `as keyof typeof Ionicons.glyphMap` 断言会吞掉这类类型错误,故需要此脚本
npm run check:icons # 等价 node scripts/check-icons.mjs --error npm run check:icons # 等价 node scripts/check-icons.mjs --error
# 间距与圆角审计(改动样式后必跑;**已设为硬门槛 —— 有命中即 exit 1**)
# 核对 AGENTS.md 的「所有卡片圆角 8px + 边距间隔一致 8px」,四个维度:
# 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白
# 卡片用**结构判定**(有 borderRadius 且有背景或描边),不用命名判定 ——
# 命名判定漏过 pendingItem / dimItem / matchBanner / salaryBox 这类「名字没 card 但是卡片」的块
# 排除项(圆 / 控件)**逐条打印**,名单改动必须写理由
# 新增卡片样式后若报错,先看是漏排除还是真违规,**不要直接往排除表里塞名字**
npm run check:spacing # 等价 node scripts/check-spacing.mjs --error
# 单元测试(src/__tests__ 下纯逻辑) # 单元测试(src/__tests__ 下纯逻辑)
npm test npm test

Loading…
Cancel
Save