diff --git a/.gitignore b/.gitignore index 0e41af8..f2e10a1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /docs/design/assets/a01-vnext/candidates/ /docs/design/assets/a01-vnext/review/ /.superpowers/ +**/__pycache__/ +*.py[cod] diff --git a/design-pipeline/manifests/a01-buttons-v2.json b/design-pipeline/manifests/a01-buttons-v2.json new file mode 100644 index 0000000..07591f5 --- /dev/null +++ b/design-pipeline/manifests/a01-buttons-v2.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "page": "A01", + "runtime": { + "url": "http://localhost:5173/#/pages/auth/a01-entry", + "chromePort": 9222 + }, + "assets": [ + { + "id": "a01-primary-button-v2", + "assetClass": "fixed-bitmap", + "source": "static/assets/foundation/opaque/a01-primary-button.png", + "output": "static/assets/foundation/transparent/a01-primary-button-v2.png", + "logicalSlot": { "widthRpx": 622, "heightRpx": 92 }, + "outputPixels": { "width": 1866, "height": 276 }, + "render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 1000000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 8, "capWidth": 180 }, + "runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + }, + { + "id": "a01-secondary-button-v2", + "assetClass": "fixed-bitmap", + "source": "static/assets/foundation/opaque/a01-secondary-button.png", + "output": "static/assets/foundation/transparent/a01-secondary-button-v2.png", + "logicalSlot": { "widthRpx": 622, "heightRpx": 100 }, + "outputPixels": { "width": 1866, "height": 300 }, + "render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 1200000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 12, "capWidth": 200 }, + "runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + } + ] +} + diff --git a/design-pipeline/manifests/a01-scroll-skins-v3.json b/design-pipeline/manifests/a01-scroll-skins-v3.json new file mode 100644 index 0000000..d280d9f --- /dev/null +++ b/design-pipeline/manifests/a01-scroll-skins-v3.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 2, + "page": "A01", + "runtime": { + "url": "http://localhost:5173/#/pages/auth/a01-entry", + "chromePort": 9222 + }, + "assets": [ + { + "id": "a01-scroll-primary-v3", + "assetClass": "fixed-bitmap", + "source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png", + "output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png", + "logicalSlot": { "widthRpx": 622, "heightRpx": 92 }, + "outputPixels": { "width": 1866, "height": 276 }, + "render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }, + "runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + }, + { + "id": "a01-scroll-secondary-v3", + "assetClass": "fixed-bitmap", + "source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png", + "output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png", + "logicalSlot": { "widthRpx": 622, "heightRpx": 100 }, + "outputPixels": { "width": 1866, "height": 300 }, + "render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }, + "runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + }, + { + "id": "a01-scroll-toast-v3", + "assetClass": "nine-slice", + "source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png", + "output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png", + "logicalSlot": { "widthRpx": 590, "heightRpx": 82 }, + "outputPixels": { "width": 1770, "height": 246 }, + "render": { "scalePolicy": "nine-slice", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 1600000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 1, "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 }, + "runtime": { "selector": ".feedback-toast", "imageSelector": ".feedback-toast__skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + }, + { + "id": "a01-scroll-dialog-v3", + "assetClass": "fixed-bitmap", + "source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png", + "output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png", + "logicalSlot": { "widthRpx": 620, "heightRpx": 520 }, + "outputPixels": { "width": 1860, "height": 1560 }, + "render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false }, + "alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 }, + "edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true }, + "quality": { "maxBytes": 3000000, "symmetry": "horizontal", "colorSpace": "sRGB" }, + "processing": { "trim": 1, "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true }, + "runtime": { "selector": ".verification-dialog", "imageSelector": ".verification-dialog__skin img" }, + "consumers": ["pages/auth/a01-entry.vue"] + } + ] +} diff --git a/design-pipeline/manifests/g01-background-candidates.json b/design-pipeline/manifests/g01-background-candidates.json new file mode 100644 index 0000000..f416a92 --- /dev/null +++ b/design-pipeline/manifests/g01-background-candidates.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "page": "G01", + "status": "candidates-pending-user-selection", + "generatedDate": "2026-07-16", + "referenceAssets": [ + "static/assets/foundation/transparent/footer-mountain-bamboo.png", + "tmp/g01-audit-02-application-gap-focused-412x915.png" + ], + "determinism": { + "modelRegeneration": "direction-only-not-pixel-identical", + "savedMasterProcessing": "pixel-identical-with-locked-dependencies" + }, + "chromaKey": { + "color": "#00ff00", + "dominanceStart": 30, + "dominanceEnd": 220, + "despillAllowance": 10 + }, + "candidates": [ + { + "id": "g01-list-background-a", + "title": "上山下亭纵向框景", + "master": "docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png", + "generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-a.png", + "processingMode": "chroma-key", + "sourcePixels": { "width": 1024, "height": 1536 }, + "prompt": "1024x1536 纵向 G01 列表背景独立资产;暖灰淡金中国水墨,远山从上方约 10% 开始,左中下亭台,右侧竹影贯穿,中央低对比度供列表文字阅读;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。" + }, + { + "id": "g01-list-background-b", + "title": "上下山水环抱框景", + "master": "docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png", + "generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-b.png", + "processingMode": "chroma-key", + "sourcePixels": { "width": 1024, "height": 1536 }, + "prompt": "1024x1536 纵向 G01 列表背景独立资产;上方连续远山与雾带、左中亭台、右侧淡金竹影、下方第二层山水,三个纵向区段都有内容但中央保持安静;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。" + }, + { + "id": "g01-list-background-c", + "title": "云竹亭台连续宣纸景", + "master": "docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png", + "generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-c.png", + "processingMode": "opaque-paper", + "sourcePixels": { "width": 1024, "height": 1536 }, + "prompt": "1024x1536 纵向 G01 列表背景独立资产;暖宣纸底,顶部 5% 即出现云气、枝叶和淡金竹影,左侧约 40% 高度放置亭台,山水从中部连续延伸到底部,中央低对比度;无文字、无 UI、无卡片、无标志、无红色。" + } + ] +} diff --git a/design-pipeline/package.json b/design-pipeline/package.json index 9eb86a4..b29503e 100644 --- a/design-pipeline/package.json +++ b/design-pipeline/package.json @@ -3,7 +3,15 @@ "private": true, "type": "module", "scripts": { - "build:a01": "node scripts/build-a01.mjs" + "build:a01": "node scripts/build-a01.mjs", + "test:v2": "node --test tests/*.test.mjs", + "validate:a01-buttons": "node scripts/validate-manifest-v2.mjs manifests/a01-buttons-v2.json", + "validate:a01-scroll-skins": "node scripts/validate-manifest-v2.mjs manifests/a01-scroll-skins-v3.json", + "rebuild:a01-buttons": "node scripts/rebuild-a01-buttons.mjs", + "verify:assets": "node scripts/verify-assets.mjs", + "build:a01-scroll-skins": "node scripts/build-a01-scroll-skins.mjs", + "verify:a01-scroll-skins": "node scripts/verify-a01-scroll-skins.mjs", + "build:g01-background-candidates": "node scripts/build-g01-backgrounds.mjs" }, "dependencies": { "sharp": "0.34.5" diff --git a/design-pipeline/requirements.txt b/design-pipeline/requirements.txt new file mode 100644 index 0000000..e7cc6c1 --- /dev/null +++ b/design-pipeline/requirements.txt @@ -0,0 +1 @@ +Pillow==12.3.0 diff --git a/design-pipeline/scripts/asset_quality.py b/design-pipeline/scripts/asset_quality.py new file mode 100644 index 0000000..0995fc1 --- /dev/null +++ b/design-pipeline/scripts/asset_quality.py @@ -0,0 +1,98 @@ +"""Deterministic PNG quality checks for generated design assets.""" + +from pathlib import Path +from typing import Any + +from PIL import Image + + +def _outer_ring_pixels(image: Image.Image, thickness: int): + width, height = image.size + thickness = max(0, min(thickness, width // 2, height // 2)) + for y in range(height): + for x in range(width): + if x < thickness or x >= width - thickness or y < thickness or y >= height - thickness: + yield image.getpixel((x, y)) + + +def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]: + """Analyze one PNG against its manifest v2 specification.""" + path = Path(path) + errors: list[str] = [] + warnings: list[str] = [] + metrics: dict[str, int] = {} + + with Image.open(path) as opened: + width, height = opened.size + mode = opened.mode + info = dict(opened.info) + has_alpha = "A" in opened.getbands() or "transparency" in opened.info + image = opened.convert("RGBA") + + expected = spec["outputPixels"] + if (width, height) != (expected["width"], expected["height"]): + errors.append( + f"dimensions expected {expected['width']}x{expected['height']}, got {width}x{height}" + ) + + alpha = spec.get("alpha", {}) + if alpha.get("required") and not has_alpha: + errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}") + + padding = int(alpha.get("transparentOuterPadding", 0)) + max_alpha = int(alpha.get("cornerMaxAlpha", 0)) + opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha) + metrics["outerPaddingViolations"] = opaque_padding + if opaque_padding: + errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}") + + edge = spec.get("edge", {}) + pixels = list(image.get_flattened_data()) + + chroma_residue = sum( + 1 + for red, green, blue, pixel_alpha in pixels + if pixel_alpha > 8 and green > 120 and green - max(red, blue) > 80 + ) + metrics["chromaResiduePixels"] = chroma_residue + if edge.get("forbidChromaResidue") and chroma_residue: + errors.append(f"chroma residue detected in {chroma_residue} visible pixels") + + light_fringe = sum( + 1 + for red, green, blue, pixel_alpha in pixels + if 0 < pixel_alpha < 255 and red > 235 and green > 235 and blue > 235 + ) + metrics["lightFringePixels"] = light_fringe + if edge.get("forbidLightFringe") and light_fringe: + errors.append(f"light fringe detected in {light_fringe} partially transparent pixels") + + transparent_rgb = sum( + 1 + for red, green, blue, pixel_alpha in pixels + if pixel_alpha == 0 and (red != 0 or green != 0 or blue != 0) + ) + metrics["transparentRgbPixels"] = transparent_rgb + if edge.get("premultipliedAlphaCheck") and transparent_rgb: + errors.append(f"transparent RGB detected in {transparent_rgb} fully transparent pixels") + + byte_size = path.stat().st_size + max_bytes = int(spec.get("quality", {}).get("maxBytes", 0)) + if max_bytes and byte_size > max_bytes: + errors.append(f"maxBytes {max_bytes} exceeded by file size {byte_size}") + + expected_color_space = spec.get("quality", {}).get("colorSpace") + if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info): + warnings.append("PNG does not declare an sRGB chunk or ICC profile") + + return { + "id": spec.get("id", path.stem), + "path": str(path), + "width": width, + "height": height, + "mode": mode, + "bytes": byte_size, + "errors": errors, + "warnings": warnings, + "metrics": metrics, + } diff --git a/design-pipeline/scripts/build-a01-scroll-skins.mjs b/design-pipeline/scripts/build-a01-scroll-skins.mjs new file mode 100644 index 0000000..5a47c81 --- /dev/null +++ b/design-pipeline/scripts/build-a01-scroll-skins.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { loadAndValidateManifest } from './manifest-v2.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json') +const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json') +const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe') +const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python') + +await loadAndValidateManifest(manifestPath, workspace) + +function run(script, args) { + const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], { + cwd: workspace, + encoding: 'utf8', + stdio: 'inherit', + }) + if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`) + if (result.status !== 0) process.exit(result.status ?? 1) +} + +run('build_scroll_skins.py', [manifestPath, '--workspace', workspace]) +run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath]) diff --git a/design-pipeline/scripts/build-g01-backgrounds.mjs b/design-pipeline/scripts/build-g01-backgrounds.mjs new file mode 100644 index 0000000..982f4a6 --- /dev/null +++ b/design-pipeline/scripts/build-g01-backgrounds.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'g01-background-candidates.json') +const reportPath = path.join(pipelineDirectory, 'generated', 'g01-background', 'build-report.json') +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + +if (manifest.schemaVersion !== 1 || manifest.page !== 'G01' || manifest.candidates?.length !== 3) { + throw new Error('G01 background manifest must declare exactly three schema v1 candidates') +} + +for (const candidate of manifest.candidates) { + const source = path.resolve(workspace, candidate.master) + const relative = path.relative(workspace, source) + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${candidate.id} master escapes workspace`) + if (!fs.existsSync(source)) throw new Error(`${candidate.id} master is missing: ${candidate.master}`) +} + +const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe') +const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python') +const result = spawnSync( + python, + [path.join(scriptDirectory, 'build_g01_backgrounds.py'), manifestPath, '--workspace', workspace, '--report', reportPath], + { cwd: workspace, encoding: 'utf8', stdio: 'inherit' } +) + +if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`) +if (result.status !== 0) process.exit(result.status ?? 1) diff --git a/design-pipeline/scripts/build_g01_backgrounds.py b/design-pipeline/scripts/build_g01_backgrounds.py new file mode 100644 index 0000000..3b57ee3 --- /dev/null +++ b/design-pipeline/scripts/build_g01_backgrounds.py @@ -0,0 +1,122 @@ +"""Deterministically build G01 background candidates from saved ImageGen masters.""" + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +from PIL import Image, PngImagePlugin + + +def process_chroma_image(source: Image.Image, config: dict[str, Any]) -> Image.Image: + """Convert a green-screen master to clean RGBA with edge despill.""" + start = int(config["dominanceStart"]) + end = int(config["dominanceEnd"]) + allowance = int(config["despillAllowance"]) + if end <= start: + raise ValueError("dominanceEnd must be greater than dominanceStart") + + output = Image.new("RGBA", source.size, (0, 0, 0, 0)) + converted = [] + for red, green, blue, source_alpha in source.convert("RGBA").get_flattened_data(): + dominance = green - max(red, blue) + if green >= 100 and dominance > start: + removal = min(1.0, (dominance - start) / (end - start)) + alpha = round(source_alpha * (1.0 - removal)) + if alpha <= 2: + converted.append((0, 0, 0, 0)) + continue + green = min(green, max(red, blue) + allowance) + converted.append((red, green, blue, alpha)) + else: + converted.append((red, green, blue, source_alpha)) + output.putdata(converted) + return output + + +def process_opaque_image(source: Image.Image) -> Image.Image: + """Normalize a paper-backed master to deterministic opaque RGBA.""" + output = source.convert("RGBA") + output.putalpha(255) + return output + + +def save_png(image: Image.Image, path: Path) -> None: + """Save a deterministic PNG with an explicit standard-sRGB chunk.""" + path.parent.mkdir(parents=True, exist_ok=True) + png_info = PngImagePlugin.PngInfo() + png_info.add(b"sRGB", b"\x00") + image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info) + + +def _resolve_inside(workspace: Path, relative_path: str) -> Path: + absolute = (workspace / relative_path).resolve() + try: + absolute.relative_to(workspace.resolve()) + except ValueError as error: + raise ValueError(f"path escapes workspace: {relative_path}") from error + return absolute + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build(manifest_path: Path, workspace: Path, report_path: Path) -> list[dict[str, Any]]: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + chroma = manifest["chromaKey"] + reports = [] + + for candidate in manifest["candidates"]: + source_path = _resolve_inside(workspace, candidate["master"]) + output_path = _resolve_inside(workspace, candidate["generatedOutput"]) + with Image.open(source_path) as opened: + expected = candidate["sourcePixels"] + if opened.size != (expected["width"], expected["height"]): + raise ValueError( + f"{candidate['id']} expected {expected['width']}x{expected['height']}, got {opened.width}x{opened.height}" + ) + if candidate["processingMode"] == "chroma-key": + output = process_chroma_image(opened, chroma) + elif candidate["processingMode"] == "opaque-paper": + output = process_opaque_image(opened) + else: + raise ValueError(f"unsupported processingMode: {candidate['processingMode']}") + + save_png(output, output_path) + reports.append( + { + "id": candidate["id"], + "mode": candidate["processingMode"], + "source": candidate["master"], + "output": candidate["generatedOutput"], + "width": output.width, + "height": output.height, + "bytes": output_path.stat().st_size, + "sha256": _sha256(output_path), + } + ) + + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") + return reports + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + reports = build(args.manifest, args.workspace.resolve(), args.report) + for report in reports: + print(f"G01-BACKGROUND-BUILD PASS {report['id']} {report['width']}x{report['height']} {report['sha256']}") + + +if __name__ == "__main__": + main() diff --git a/design-pipeline/scripts/build_scroll_skins.py b/design-pipeline/scripts/build_scroll_skins.py new file mode 100644 index 0000000..18441ef --- /dev/null +++ b/design-pipeline/scripts/build_scroll_skins.py @@ -0,0 +1,176 @@ +"""Build exact A01 scroll-skin outputs from chroma-backed visual masters.""" + +import argparse +import json +import math +from pathlib import Path + +from PIL import Image, PngImagePlugin + + +def remove_chroma_background( + image: Image.Image, + key: tuple[int, int, int], + tolerance: int, + feather: int = 72, +) -> Image.Image: + source = image.convert("RGBA") + cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0)) + result = [] + key_red, key_green, key_blue = key + + for red, green, blue, source_alpha in source.get_flattened_data(): + distance = math.sqrt( + (red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2 + ) + alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather)) + alpha_fraction *= source_alpha / 255 + if alpha_fraction <= 0: + result.append((0, 0, 0, 0)) + continue + + recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction) + recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction) + recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction) + result.append( + ( + max(0, min(255, recovered_red)), + max(0, min(255, recovered_green)), + max(0, min(255, recovered_blue)), + round(alpha_fraction * 255), + ) + ) + + cleaned.putdata(result) + return cleaned + + +def trim_transparent(image: Image.Image) -> Image.Image: + alpha = image.getchannel("A") + bounds = alpha.getbbox() + if bounds is None: + raise ValueError("chroma removal left no visible artwork") + return image.crop(bounds) + + +def stretch_safe_center( + image: Image.Image, + *, + output_size: tuple[int, int], + cap_width: int, + padding: int, +) -> Image.Image: + output_width, output_height = output_size + inner_width = output_width - padding * 2 + inner_height = output_height - padding * 2 + if inner_width <= 0 or inner_height <= 0: + raise ValueError("transparent padding leaves no drawable area") + if cap_width <= 0 or cap_width * 2 >= image.width: + raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}") + + target_cap_width = max(1, round(cap_width * inner_height / image.height)) + center_width = inner_width - target_cap_width * 2 + if center_width <= 0: + raise ValueError("scaled caps leave no room for the stretch-safe center") + + left = image.crop((0, 0, cap_width, image.height)) + center = image.crop((cap_width, 0, image.width - cap_width, image.height)) + right = image.crop((image.width - cap_width, 0, image.width, image.height)) + resampling = Image.Resampling.LANCZOS + left = left.resize((target_cap_width, inner_height), resampling) + center = center.resize((center_width, inner_height), resampling) + right = right.resize((target_cap_width, inner_height), resampling) + + output = Image.new("RGBA", output_size, (0, 0, 0, 0)) + output.alpha_composite(left, (padding, padding)) + output.alpha_composite(center, (padding + target_cap_width, padding)) + output.alpha_composite(right, (output_width - padding - target_cap_width, padding)) + return output + + +def sanitize_output_edges(image: Image.Image) -> Image.Image: + cleaned = [] + for red, green, blue, alpha in image.convert("RGBA").get_flattened_data(): + chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80 + light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235 + if alpha == 0 or chroma_residue or light_fringe: + cleaned.append((0, 0, 0, 0)) + else: + cleaned.append((red, green, blue, alpha)) + output = Image.new("RGBA", image.size, (0, 0, 0, 0)) + output.putdata(cleaned) + return output + + +def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image: + alpha = image.getchannel("A") + rgb = image.convert("RGB").quantize( + colors=colors, + method=Image.Quantize.MEDIANCUT, + dither=Image.Dither.NONE, + ).convert("RGB") + output = rgb.convert("RGBA") + output.putalpha(alpha) + return sanitize_output_edges(output) + + +def parse_hex_color(value: str) -> tuple[int, int, int]: + if len(value) != 7 or not value.startswith("#"): + raise ValueError(f"invalid RGB hex color: {value}") + return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5)) + + +def build_asset(asset: dict, workspace: Path) -> None: + processing = asset["processing"] + alpha = asset["alpha"] + pixels = asset["outputPixels"] + source_path = workspace / asset["source"] + output_path = workspace / asset["output"] + + with Image.open(source_path) as source: + cleaned = remove_chroma_background( + source, + parse_hex_color(processing["keyColor"]), + int(processing["keyTolerance"]), + ) + artwork = trim_transparent(cleaned) + output = stretch_safe_center( + artwork, + output_size=(pixels["width"], pixels["height"]), + cap_width=int(processing["capWidth"]), + padding=int(alpha["transparentOuterPadding"]), + ) + palette_colors = int(processing.get("paletteColors", 192)) + output = sanitize_output_edges(output) + if processing.get("indexedPng"): + output = output.quantize( + colors=palette_colors, + method=Image.Quantize.FASTOCTREE, + dither=Image.Dither.NONE, + ) + else: + output = reduce_png_palette(output, colors=palette_colors) + + output_path.parent.mkdir(parents=True, exist_ok=True) + png_info = PngImagePlugin.PngInfo() + png_info.add(b"sRGB", b"\x00") + output.save(output_path, format="PNG", optimize=True, pnginfo=png_info) + print(f"BUILT {asset['id']} -> {asset['output']}") + + +def build_manifest(manifest_path: Path, workspace: Path) -> None: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + for asset in manifest["assets"]: + build_asset(asset, workspace) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("--workspace", type=Path, required=True) + args = parser.parse_args() + build_manifest(args.manifest.resolve(), args.workspace.resolve()) + + +if __name__ == "__main__": + main() diff --git a/design-pipeline/scripts/manifest-v2.mjs b/design-pipeline/scripts/manifest-v2.mjs new file mode 100644 index 0000000..b3967aa --- /dev/null +++ b/design-pipeline/scripts/manifest-v2.mjs @@ -0,0 +1,121 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const allowedAssetClasses = new Set([ + 'code-native', + 'fixed-bitmap', + 'transparent-overlay', + 'nine-slice', + 'tile-texture' +]) + +const requireObject = (value, label) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value +} + +const requireString = (value, label) => { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`) + return value +} + +const requirePositiveNumber = (value, label) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a positive number`) + } + return value +} + +const resolveWorkspacePath = (workspace, relativePath, label) => { + requireString(relativePath, label) + const absolutePath = path.resolve(workspace, relativePath) + const relative = path.relative(workspace, absolutePath) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`${label} escapes workspace: ${relativePath}`) + } + return absolutePath +} + +const requireBoolean = (value, label) => { + if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`) + return value +} + +export const validateManifest = (manifest, workspace) => { + requireObject(manifest, 'manifest') + if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2') + requireString(manifest.page, 'page') + requireObject(manifest.runtime, 'runtime') + requireString(manifest.runtime.url, 'runtime.url') + requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort') + if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array') + + const ids = new Set() + for (const asset of manifest.assets) { + requireObject(asset, 'asset') + const id = requireString(asset.id, 'asset.id') + if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`) + ids.add(id) + + if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`) + resolveWorkspacePath(workspace, asset.source, `${id}.source`) + resolveWorkspacePath(workspace, asset.output, `${id}.output`) + + const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`) + const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`) + const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`) + const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`) + const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`) + const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`) + + const render = requireObject(asset.render, `${id}.render`) + requireString(render.scalePolicy, `${id}.render.scalePolicy`) + requireString(render.uniMode, `${id}.render.uniMode`) + requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`) + if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') { + throw new Error(`${id} uniform-only asset cannot use scaleToFill`) + } + const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1) + if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) { + throw new Error(`${id} ratio drift exceeds 0.5%`) + } + + const alpha = requireObject(asset.alpha, `${id}.alpha`) + requireBoolean(alpha.required, `${id}.alpha.required`) + requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`) + if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) { + throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`) + } + + const edge = requireObject(asset.edge, `${id}.edge`) + requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`) + requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`) + requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`) + + const quality = requireObject(asset.quality, `${id}.quality`) + requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`) + requireString(quality.colorSpace, `${id}.quality.colorSpace`) + + const processing = requireObject(asset.processing, `${id}.processing`) + requirePositiveNumber(processing.trim, `${id}.processing.trim`) + requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`) + + const runtime = requireObject(asset.runtime, `${id}.runtime`) + requireString(runtime.selector, `${id}.runtime.selector`) + requireString(runtime.imageSelector, `${id}.runtime.imageSelector`) + + if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) { + throw new Error(`${id}.consumers must be a non-empty array`) + } + for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`) + } + return manifest +} + +export const loadAndValidateManifest = async (filePath, workspace) => { + const manifest = JSON.parse(await readFile(filePath, 'utf8')) + return validateManifest(manifest, workspace) +} + diff --git a/design-pipeline/scripts/rebuild-a01-buttons.mjs b/design-pipeline/scripts/rebuild-a01-buttons.mjs new file mode 100644 index 0000000..71e2d78 --- /dev/null +++ b/design-pipeline/scripts/rebuild-a01-buttons.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { loadAndValidateManifest } from './manifest-v2.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json') +const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json') + +await loadAndValidateManifest(manifestPath, workspace) + +const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe') +const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python') + +function runPython(script, args) { + const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], { + cwd: workspace, + encoding: 'utf8', + stdio: 'inherit', + }) + if (result.error) { + throw new Error(`无法启动 Python(${python}):${result.error.message}`) + } + if (result.status !== 0) process.exit(result.status ?? 1) +} + +runPython('rebuild_a01_buttons.py', [manifestPath, '--workspace', workspace]) +runPython('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath]) diff --git a/design-pipeline/scripts/rebuild_a01_buttons.py b/design-pipeline/scripts/rebuild_a01_buttons.py new file mode 100644 index 0000000..5884a93 --- /dev/null +++ b/design-pipeline/scripts/rebuild_a01_buttons.py @@ -0,0 +1,89 @@ +"""Rebuild A01 button skins with deterministic horizontal three-slice scaling.""" + +import argparse +import json +from pathlib import Path + +from PIL import Image, PngImagePlugin + + +def rebuild_button( + source_path: Path, + output_path: Path, + *, + output_size: tuple[int, int], + trim: int, + padding: int, + cap_width: int, +) -> None: + """Trim contaminated edges, preserve both caps, and stretch only the center.""" + with Image.open(source_path) as opened: + source = opened.convert("RGBA") + + if trim < 0 or trim * 2 >= min(source.size): + raise ValueError(f"invalid trim {trim} for source size {source.size}") + if trim: + source = source.crop((trim, trim, source.width - trim, source.height - trim)) + + if cap_width <= 0 or cap_width * 2 >= source.width: + raise ValueError(f"invalid capWidth {cap_width} for cropped width {source.width}") + + output_width, output_height = output_size + inner_width = output_width - padding * 2 + inner_height = output_height - padding * 2 + if inner_width <= 0 or inner_height <= 0: + raise ValueError(f"padding {padding} leaves no drawable area in {output_size}") + + target_cap_width = max(1, round(cap_width * inner_height / source.height)) + if target_cap_width * 2 >= inner_width: + raise ValueError("scaled caps leave no room for the center slice") + + left = source.crop((0, 0, cap_width, source.height)) + center = source.crop((cap_width, 0, source.width - cap_width, source.height)) + right = source.crop((source.width - cap_width, 0, source.width, source.height)) + + resampling = Image.Resampling.LANCZOS + left = left.resize((target_cap_width, inner_height), resampling) + center = center.resize((inner_width - target_cap_width * 2, inner_height), resampling) + right = right.resize((target_cap_width, inner_height), resampling) + + output = Image.new("RGBA", output_size, (0, 0, 0, 0)) + output.alpha_composite(left, (padding, padding)) + output.alpha_composite(center, (padding + target_cap_width, padding)) + output.alpha_composite(right, (output_width - padding - target_cap_width, padding)) + + output_path.parent.mkdir(parents=True, exist_ok=True) + png_info = PngImagePlugin.PngInfo() + png_info.add(b"sRGB", b"\x00") + output.save(output_path, format="PNG", optimize=True, pnginfo=png_info) + + +def rebuild_manifest(manifest_path: Path, workspace_root: Path) -> None: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + for asset in manifest["assets"]: + processing = asset["processing"] + alpha = asset["alpha"] + pixels = asset["outputPixels"] + source = workspace_root / asset["source"] + output = workspace_root / asset["output"] + rebuild_button( + source, + output, + output_size=(pixels["width"], pixels["height"]), + trim=processing["trim"], + padding=alpha["transparentOuterPadding"], + cap_width=processing["capWidth"], + ) + print(f"REBUILT {asset['id']} -> {asset['output']}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("--workspace", type=Path, required=True) + args = parser.parse_args() + rebuild_manifest(args.manifest.resolve(), args.workspace.resolve()) + + +if __name__ == "__main__": + main() diff --git a/design-pipeline/scripts/validate-manifest-v2.mjs b/design-pipeline/scripts/validate-manifest-v2.mjs new file mode 100644 index 0000000..39c5b62 --- /dev/null +++ b/design-pipeline/scripts/validate-manifest-v2.mjs @@ -0,0 +1,15 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadAndValidateManifest } from './manifest-v2.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestArgument = process.argv[2] + +if (!manifestArgument) throw new Error('Usage: node validate-manifest-v2.mjs ') + +const manifestPath = path.resolve(pipelineDirectory, manifestArgument) +await loadAndValidateManifest(manifestPath, workspace) +process.stdout.write(`MANIFEST-V2 PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`) + diff --git a/design-pipeline/scripts/verify-a01-scroll-skins.mjs b/design-pipeline/scripts/verify-a01-scroll-skins.mjs new file mode 100644 index 0000000..8cb9088 --- /dev/null +++ b/design-pipeline/scripts/verify-a01-scroll-skins.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { loadAndValidateManifest } from './manifest-v2.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json') +const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json') +const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe') +const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python') + +await loadAndValidateManifest(manifestPath, workspace) +const result = spawnSync( + python, + [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath], + { cwd: workspace, encoding: 'utf8', stdio: 'inherit' } +) +if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`) +if (result.status !== 0) process.exit(result.status ?? 1) diff --git a/design-pipeline/scripts/verify-assets.mjs b/design-pipeline/scripts/verify-assets.mjs new file mode 100644 index 0000000..2ea5e34 --- /dev/null +++ b/design-pipeline/scripts/verify-assets.mjs @@ -0,0 +1,24 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { loadAndValidateManifest } from './manifest-v2.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(scriptDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json') +const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json') + +await loadAndValidateManifest(manifestPath, workspace) + +const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe') +const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python') +const result = spawnSync( + python, + [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath], + { cwd: workspace, encoding: 'utf8', stdio: 'inherit' } +) + +if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`) +if (result.status !== 0) process.exit(result.status ?? 1) diff --git a/design-pipeline/scripts/verify-assets.py b/design-pipeline/scripts/verify-assets.py new file mode 100644 index 0000000..ccf68fa --- /dev/null +++ b/design-pipeline/scripts/verify-assets.py @@ -0,0 +1,47 @@ +"""Verify generated assets declared in a manifest v2 file.""" + +import argparse +import json +import sys +from pathlib import Path + +from asset_quality import analyze_asset + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + reports = [] + failed = False + for asset in manifest["assets"]: + output = args.workspace / asset["output"] + if not output.is_file(): + report = { + "id": asset["id"], + "path": str(output), + "errors": ["output file is missing"], + "warnings": [], + "metrics": {}, + } + else: + report = analyze_asset(output, asset) + reports.append(report) + if report["errors"]: + failed = True + print(f"ASSET-QUALITY FAIL {asset['id']}: {'; '.join(report['errors'])}") + else: + print(f"ASSET-QUALITY PASS {asset['id']}") + + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") + if failed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/design-pipeline/tests/g01-background-candidates.test.mjs b/design-pipeline/tests/g01-background-candidates.test.mjs new file mode 100644 index 0000000..9bb651b --- /dev/null +++ b/design-pipeline/tests/g01-background-candidates.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const workspace = path.resolve(testDirectory, '..', '..') +const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json') +const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs') + +test('G01 background candidate manifest preserves three portable masters', async () => { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + assert.equal(manifest.schemaVersion, 1) + assert.equal(manifest.page, 'G01') + assert.equal(manifest.status, 'candidates-pending-user-selection') + assert.equal(manifest.candidates.length, 3) + + const ids = new Set() + for (const candidate of manifest.candidates) { + assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`) + ids.add(candidate.id) + assert.match(candidate.prompt, /1024x1536/) + assert.deepEqual(candidate.sourcePixels, { width: 1024, height: 1536 }) + assert.ok(['chroma-key', 'opaque-paper'].includes(candidate.processingMode)) + + for (const key of ['master', 'generatedOutput']) { + const absolute = path.resolve(workspace, candidate[key]) + const relative = path.relative(workspace, absolute) + assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`) + } + } +}) + +test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => { + const wrapper = await readFile(wrapperPath, 'utf8') + assert.doesNotMatch(wrapper, /from ['"]sharp['"]/) + assert.match(wrapper, /build_g01_backgrounds\.py/) +}) diff --git a/design-pipeline/tests/manifest-v2.test.mjs b/design-pipeline/tests/manifest-v2.test.mjs new file mode 100644 index 0000000..c5c2864 --- /dev/null +++ b/design-pipeline/tests/manifest-v2.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { validateManifest } from '../scripts/manifest-v2.mjs' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const workspace = path.resolve(testDirectory, '..', '..') + +const validAsset = () => ({ + id: 'a01-primary-button-v2', + assetClass: 'fixed-bitmap', + source: 'static/assets/foundation/opaque/a01-primary-button.png', + output: 'static/assets/foundation/transparent/a01-primary-button-v2.png', + logicalSlot: { widthRpx: 622, heightRpx: 92 }, + outputPixels: { width: 1866, height: 276 }, + render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false }, + alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 }, + edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true }, + quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' }, + processing: { trim: 8, capWidth: 180 }, + runtime: { selector: '.login-submit', imageSelector: '.button-skin img' }, + consumers: ['pages/auth/a01-entry.vue'] +}) + +const validManifest = () => ({ + schemaVersion: 2, + page: 'A01', + runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 }, + assets: [validAsset()] +}) + +test('accepts a complete manifest v2', () => { + const manifest = validManifest() + assert.equal(validateManifest(manifest, workspace), manifest) +}) + +test('rejects duplicate asset ids', () => { + const manifest = validManifest() + manifest.assets.push(validAsset()) + assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i) +}) + +test('rejects paths that escape the workspace', () => { + const manifest = validManifest() + manifest.assets[0].output = '../outside.png' + assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i) +}) + +test('rejects a fixed bitmap whose output ratio differs from its slot', () => { + const manifest = validManifest() + manifest.assets[0].outputPixels.height = 300 + assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i) +}) + +test('rejects scaleToFill for uniform-only assets', () => { + const manifest = validManifest() + manifest.assets[0].render.uniMode = 'scaleToFill' + assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i) +}) + +test('rejects unknown asset classes', () => { + const manifest = validManifest() + manifest.assets[0].assetClass = 'mystery' + assert.throws(() => validateManifest(manifest, workspace), /assetClass/i) +}) + +test('rejects assets without consumers', () => { + const manifest = validManifest() + manifest.assets[0].consumers = [] + assert.throws(() => validateManifest(manifest, workspace), /consumers/i) +}) diff --git a/design-pipeline/tests/scroll-skins-v3.test.mjs b/design-pipeline/tests/scroll-skins-v3.test.mjs new file mode 100644 index 0000000..f5ebbd5 --- /dev/null +++ b/design-pipeline/tests/scroll-skins-v3.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { validateManifest } from '../scripts/manifest-v2.mjs' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const pipelineDirectory = path.resolve(testDirectory, '..') +const workspace = path.resolve(pipelineDirectory, '..') +const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json') + +test('declares the approved A01 scroll-skin family', async () => { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + validateManifest(manifest, workspace) + + assert.equal(manifest.assets.length, 4) + assert.deepEqual( + manifest.assets.map(({ id }) => id), + ['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3'] + ) + assert.deepEqual( + manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]), + [[1866, 276], [1866, 300], [1770, 246], [1860, 1560]] + ) + assert.equal(manifest.assets[2].assetClass, 'nine-slice') + assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice') + assert.equal(manifest.assets[3].render.uniMode, 'aspectFit') + assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue'))) +}) diff --git a/design-pipeline/tests/test_asset_quality.py b/design-pipeline/tests/test_asset_quality.py new file mode 100644 index 0000000..e65e41d --- /dev/null +++ b/design-pipeline/tests/test_asset_quality.py @@ -0,0 +1,100 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from asset_quality import analyze_asset + + +class AssetQualityTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def spec(self): + return { + "id": "test-button", + "outputPixels": {"width": 32, "height": 16}, + "alpha": {"required": True, "transparentOuterPadding": 2, "cornerMaxAlpha": 0}, + "edge": { + "forbidChromaResidue": True, + "forbidLightFringe": True, + "premultipliedAlphaCheck": True, + }, + "quality": {"maxBytes": 10000, "colorSpace": "sRGB"}, + } + + def clean_image(self): + image = Image.new("RGBA", (32, 16), (0, 0, 0, 0)) + for y in range(2, 14): + for x in range(2, 30): + image.putpixel((x, y), (150, 20, 12, 255)) + return image + + def save(self, image, name="asset.png"): + path = self.root / name + image.save(path, format="PNG") + return path + + def error_text(self, report): + return " ".join(report["errors"]) + + def test_accepts_clean_rgba_asset(self): + report = analyze_asset(self.save(self.clean_image()), self.spec()) + self.assertEqual([], report["errors"]) + self.assertEqual(32, report["width"]) + self.assertEqual(16, report["height"]) + + def test_accepts_indexed_png_with_real_transparency(self): + indexed = self.clean_image().quantize( + colors=16, + method=Image.Quantize.FASTOCTREE, + dither=Image.Dither.NONE, + ) + report = analyze_asset(self.save(indexed), self.spec()) + self.assertEqual([], report["errors"]) + self.assertEqual("P", report["mode"]) + + def test_rejects_wrong_dimensions(self): + report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec()) + self.assertIn("dimensions", self.error_text(report)) + + def test_rejects_opaque_outer_padding(self): + image = self.clean_image() + image.putpixel((0, 0), (120, 30, 20, 255)) + report = analyze_asset(self.save(image), self.spec()) + self.assertIn("outer padding", self.error_text(report)) + + def test_rejects_visible_green_residue(self): + image = self.clean_image() + image.putpixel((16, 8), (0, 255, 0, 255)) + report = analyze_asset(self.save(image), self.spec()) + self.assertIn("chroma residue", self.error_text(report)) + + def test_rejects_light_partially_transparent_fringe(self): + image = self.clean_image() + image.putpixel((2, 8), (250, 250, 250, 128)) + report = analyze_asset(self.save(image), self.spec()) + self.assertIn("light fringe", self.error_text(report)) + + def test_rejects_hidden_rgb_in_fully_transparent_pixels(self): + image = self.clean_image() + image.putpixel((0, 0), (255, 255, 255, 0)) + report = analyze_asset(self.save(image), self.spec()) + self.assertIn("transparent RGB", self.error_text(report)) + + def test_rejects_file_over_max_bytes(self): + spec = self.spec() + spec["quality"]["maxBytes"] = 8 + report = analyze_asset(self.save(self.clean_image()), spec) + self.assertIn("maxBytes", self.error_text(report)) + + +if __name__ == "__main__": + unittest.main() diff --git a/design-pipeline/tests/test_build_g01_backgrounds.py b/design-pipeline/tests/test_build_g01_backgrounds.py new file mode 100644 index 0000000..f5e230e --- /dev/null +++ b/design-pipeline/tests/test_build_g01_backgrounds.py @@ -0,0 +1,64 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png + + +class G01BackgroundBuilderTests(unittest.TestCase): + def config(self): + return { + "dominanceStart": 30, + "dominanceEnd": 220, + "despillAllowance": 10, + } + + def test_pure_green_becomes_clean_transparency(self): + source = Image.new("RGB", (2, 1), (0, 255, 0)) + output = process_chroma_image(source, self.config()) + self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0))) + + def test_warm_artwork_remains_opaque(self): + source = Image.new("RGB", (1, 1), (210, 190, 150)) + output = process_chroma_image(source, self.config()) + self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0))) + + def test_antialiased_green_edge_is_translucent_and_despilled(self): + source = Image.new("RGB", (1, 1), (100, 200, 90)) + output = process_chroma_image(source, self.config()) + red, green, blue, alpha = output.getpixel((0, 0)) + self.assertGreater(alpha, 0) + self.assertLess(alpha, 255) + self.assertLessEqual(green, max(red, blue) + 10) + + def test_opaque_master_is_normalized_to_rgba_without_transparency(self): + source = Image.new("RGB", (1, 1), (240, 235, 220)) + output = process_opaque_image(source) + self.assertEqual("RGBA", output.mode) + self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0))) + + def test_saved_png_declares_srgb_color_profile(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "asset.png" + save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path) + with Image.open(path) as opened: + self.assertIn("srgb", opened.info) + + def test_saved_png_bytes_are_deterministic(self): + with tempfile.TemporaryDirectory() as directory: + first = Path(directory) / "first.png" + second = Path(directory) / "second.png" + image = Image.new("RGBA", (2, 2), (240, 235, 220, 255)) + save_png(image, first) + save_png(image, second) + self.assertEqual(first.read_bytes(), second.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/design-pipeline/tests/test_build_scroll_skins.py b/design-pipeline/tests/test_build_scroll_skins.py new file mode 100644 index 0000000..3ab3784 --- /dev/null +++ b/design-pipeline/tests/test_build_scroll_skins.py @@ -0,0 +1,92 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center + + +class BuildScrollSkinsTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def test_chroma_background_becomes_transparent_black(self): + image = Image.new("RGB", (4, 2), (0, 255, 0)) + image.putpixel((1, 0), (180, 30, 20)) + + cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80) + + self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0))) + self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0))) + + def test_safe_center_stretch_preserves_caps_and_exact_size(self): + image = Image.new("RGBA", (30, 10), (150, 20, 10, 255)) + for y in range(10): + for x in range(5): + image.putpixel((x, y), (220, 170, 40, 255)) + image.putpixel((29 - x, y), (220, 170, 40, 255)) + + output = stretch_safe_center( + image, + output_size=(60, 20), + cap_width=5, + padding=2, + ) + + self.assertEqual((60, 20), output.size) + self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0))) + self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10))) + self.assertEqual((150, 20, 10, 255), output.getpixel((30, 10))) + self.assertEqual((220, 170, 40, 255), output.getpixel((56, 10))) + + def test_sanitizes_only_forbidden_edge_artifacts(self): + image = Image.new("RGBA", (3, 1), (248, 240, 220, 255)) + image.putpixel((0, 0), (0, 255, 0, 128)) + image.putpixel((1, 0), (250, 250, 250, 96)) + + cleaned = sanitize_output_edges(image) + + self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0))) + self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0))) + self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0))) + + def test_build_asset_honors_declared_palette_size(self): + source = Image.new("RGB", (32, 16), (0, 255, 0)) + for y in range(1, 15): + for x in range(1, 31): + source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10)) + source_path = self.root / "source.png" + output_path = self.root / "output.png" + source.save(source_path) + asset = { + "id": "palette-test", + "source": "source.png", + "output": "output.png", + "outputPixels": {"width": 60, "height": 30}, + "alpha": {"transparentOuterPadding": 1}, + "processing": { + "capWidth": 4, + "keyColor": "#00FF00", + "keyTolerance": 80, + "paletteColors": 8, + }, + } + + build_asset(asset, self.root) + + with Image.open(output_path) as output: + visible_colors = { + pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0 + } + self.assertLessEqual(len(visible_colors), 8) + + +if __name__ == "__main__": + unittest.main() diff --git a/design-pipeline/tests/test_rebuild_a01_buttons.py b/design-pipeline/tests/test_rebuild_a01_buttons.py new file mode 100644 index 0000000..e84d2a4 --- /dev/null +++ b/design-pipeline/tests/test_rebuild_a01_buttons.py @@ -0,0 +1,49 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image, ImageDraw + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from rebuild_a01_buttons import rebuild_button + + +class RebuildA01ButtonTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def make_source(self): + image = Image.new("RGBA", (60, 30), (0, 255, 0, 255)) + draw = ImageDraw.Draw(image) + draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255)) + draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255)) + draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255)) + return image + + def test_rebuilds_three_slice_into_clean_transparent_canvas(self): + source = self.root / "source.png" + output = self.root / "output.png" + self.make_source().save(source) + + rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10) + + with Image.open(output) as image: + self.assertEqual("RGBA", image.mode) + self.assertEqual((180, 60), image.size) + self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0))) + self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59))) + visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8] + self.assertFalse( + any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels) + ) + self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0]) + self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/design-qa.md b/design-qa.md index 07770a4..3d89681 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,3 +1,37 @@ +# Design QA — A01 卷轴祥云四件套 v3 + +- approved visual source: `docs/design/assets/a01-vnext/A01-shared-scroll-skins-with-dialog-approved.png` +- H5 runtime evidence: `tmp/a01-scroll-v3-password-412x915.png`、`tmp/a01-scroll-v3-toast-412x915.png`、`tmp/a01-scroll-v3-dialog-412x915.png` +- viewport: 412×915;自动运行 smoke 另覆盖 320×568、360×640、360×800 +- scope: 登录主按钮、微信次按钮、反馈 Toast;安全验证弹窗已按用户最新决定降为流程占位,不属于最终视觉 + +## Findings + +当前真实 H5 运行图未发现四件套相关的 P0/P1/P2 问题。 + +- 视觉统一:四件套均采用用户选定的“方案 2”卷轴祥云体系,主按钮为朱砂底,次按钮、Toast 与弹窗为暖宣纸底,古金边框和角饰属于同一套装饰语言。 +- 资产质量:四张运行资产外角透明,没有旧按钮的白边/浅绿边;按钮与弹窗使用等比显示,Toast 使用固定切片的 `border-image`,没有整体拉伸角饰。 +- 内容分层:按钮文字、微信图标、Toast 文案和弹窗内容仍由 Vue 承载,位图只负责复杂装饰表面;文字可访问性和状态切换不被烘焙进图片。 +- 真实运行:412×915 的密码/短信、掩码/明文、无效手机号 Toast、协议错误和协议勾选状态均已实际触发;320×568 继续允许自然纵向滚动。 +- 用户决定:2026-07-16 已明确通过除真实滑动验证外的 7 类 A01 H5 视觉状态,并确认缩小后的品牌 Logo;真实滑动验证内部视觉使用接口组件自带样式,延期到接入阶段审核。 + +## Evidence limits + +除真实滑动验证外的 7 类 H5 状态已取得用户视觉确认,但这仍不是 A01 整页冻结。`tmp/` 截图不作为长期交接代表图;真实滑动验证组件、Android/HBuilderX 的状态栏、安全区、软键盘、字体放大、触摸和返回手势仍未复核,因此 A01 保持 `[~]`。 + +## Implementation checklist + +- [x] 四张独立 v3 资产通过 manifest、Node/Python 测试与像素质量门 +- [x] A01 静态合同、透明角审计和四档 H5 运行 smoke +- [x] 用户确认 7 类非滑动验证 H5 视觉状态与缩小后的品牌 Logo +- [ ] 接入真实滑动验证组件并审核其自带样式 +- [ ] Android/HBuilderX 真机或模拟器复核 +- [ ] 用户验收并冻结 A01 整页 + +final result: partially accepted (7 H5 visual states accepted; slider verification and Android review pending) + +--- + # Design QA — T04 新增亲属 - representative contact sheet: `docs/design/screens/handoff/2026-07-16/T04-current-form-success-contact-720x800.png` @@ -316,9 +350,10 @@ final result: passed (internal H5 candidate QA only; user review pending) # Design QA — A06 阻断登录状态 +- archive status: 2026-07-16 用户确认当前样式没有问题,但当前流程暂时不用该页;已从 `pages.json` 和活动验收队列移除,源码、测试和证据保留。 - source visual truth path: A01/A04/A05 红色宗祠水墨认证系列与 `docs/验收规划.md` A06 最新产品合同 -- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A06-current-risk-412x915.png` -- viewport: 412×915;运行 smoke 另查 320×568、360×800 +- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A06-current-risk-v2-412x915.png` +- viewport: 412×915;运行 smoke 另查 320×568、360×640、360×800 - state: 账号冻结、账号停用、风险限制、未知旧状态回落、自定义恢复说明层 ## Findings @@ -327,12 +362,12 @@ final result: passed (internal H5 candidate QA only; user review pending) - 产品边界:旧 normal/failed/register/wechat 状态已移除;A06 仅保留冻结、停用、风险限制,普通登录错误继续由 A01 就近承接。 - 信息完整性:每种状态明确展示限制原因、影响范围和恢复方式,不只给出模糊失败提示。 -- 视觉与反馈:保持认证系列底板和品牌印;恢复入口使用自定义说明层,不调用原生 Toast/Modal。 -- 尺寸适配:三档无横向溢出,320×568 按正常比例自然滚动。 +- 视觉与反馈:Logo 已同步 A01 的 `184×221rpx` 槽位;页面继承全局楷体,标题分隔、两处朱砂按钮和恢复卷轴均使用 A01 新资产,不再引用旧按钮或旧面板。 +- 尺寸适配:四档无横向溢出,320×568 与 360×640 按正常比例自然滚动。 ## Evidence limits -当前结论仅为 HBuilderX H5 + Chrome 内部 QA,不等于用户验收;真实状态来源、账号申诉服务、Android 返回手势和字体放大仍待功能/真机阶段。 +当前结论仅说明用户对封存前 H5 样式没有异议,不等于完整页面验收或冻结;真实状态来源、账号申诉服务、Android 返回手势和字体放大均未验证。封存期间运行 smoke 明确 SKIP;恢复路由后必须重新执行。 ## Implementation checklist @@ -340,79 +375,82 @@ final result: passed (internal H5 candidate QA only; user review pending) - [x] 原因、影响范围、恢复入口完整 - [x] 未知旧状态安全回落到风险限制 - [x] 自定义恢复说明层与返回 A01 -- [x] 三档 H5 响应式和运行烟测 +- [x] A01 新 Logo、全局字体、标题分隔、两处按钮和恢复卷轴 +- [x] 封存前四档 H5 响应式和运行烟测 +- [x] 从活动路由和验收表移除,源码/测试/证据保留 - [ ] Android/HBuilderX 真机或模拟器复核 -- [ ] 用户验收 +- [ ] 恢复路由后的用户逐态验收 -final result: passed (internal H5 candidate QA only; user review pending) +final result: archived (H5 style had no user objection; not an accepted or frozen active page) --- # Design QA — A05 重设密码 -- source visual truth path: A01/A04 红色宗祠水墨认证系列 -- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A05-current-reset-412x915.png` -- viewport: 412×915;运行 smoke 另查 320×568、360×800 -- state: 默认表单、手机号错误、发送验证码前验证、验证关闭保值、发送反馈、密码不一致、自定义成功结果 +- source visual truth path: A01/A04 已确定的红色宗祠水墨认证系列与 `docs/design/screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png` +- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A05-current-reset-v2-412x915.png` +- viewport: 412×915;运行 smoke 另查 320×568、360×640、360×800 +- state: 默认表单、手机号错误、滑动验证待接入 Toast、全字段错误、密码不一致、自定义成功结果 ## Findings 没有遗留的 H5 P0/P1/P2 问题。 -- 视觉连续性:使用同一宗祠水墨底板、独立品牌印、朱砂主按钮和宣纸表单,旧大面板已移除。 -- 流程顺序:获取验证码先校验手机号,再打开自定义行为验证;重设提交不重复验证。 -- 错误与结果:四字段错误就近显示,验证码发送反馈和重设成功结果均为项目内自定义组件,不使用原生 UniApp 提示。 -- 尺寸适配:三档无横向溢出,320×568 保持正常比例并自然滚动。 +- 视觉连续性:Logo 已同步 A01 的 `184×221rpx` 槽位;页面继承全局楷体,标题分隔、页面主按钮、成功结果按钮和 Toast 均迁移到 A01 新资产体系。 +- 流程边界:获取验证码先校验手机号;通过后只提示“滑动验证待接口接入”,不再用旧面板伪造验证完成、验证码已发送或重新获取状态。 +- 错误与结果:四字段错误继续就近显示;成功结果使用新卷轴容器和新版朱砂按钮,不调用原生 UniApp 提示。 +- 尺寸适配:四档无横向溢出;320×568 和 360×640 保持正常比例并自然纵向滚动。 ## Evidence limits -当前结论仅为 HBuilderX H5 + Chrome 内部 QA,不等于用户验收;真实短信、行为验证令牌、重设接口、Android 软键盘与成功后手机号回填属于后续功能/真机复核。 +2026-07-16 用户已逐态确认当前 7 类 H5 视觉状态。真实滑动验证使用接口组件自带样式;真实短信、重设接口、Android 状态栏、安全区、软键盘、字体放大和返回手势属于后续功能/真机复核。返回 A01 后回填手机号并聚焦密码框由用户明确延期到真实重置接口链路接入时解决,不阻塞当前样式验收。 ## Implementation checklist -- [x] A01/A04 同系列视觉与真实资产 -- [x] 发送验证码前自定义行为验证 -- [x] 关闭验证保留表单与自定义发送反馈 +- [x] A01/A04 同系列 Logo、全局字体、标题分隔、按钮与 Toast +- [x] 移除旧验证面板和伪验证码发送状态 +- [x] 手机号通过后使用滑动验证待接入反馈并保留表单 - [x] 四字段页内校验与自定义成功结果 -- [x] 三档 H5 响应式和运行烟测 +- [x] 四档 H5 响应式和真实运行烟测 +- [x] 用户确认当前 7 类 H5 视觉状态(接口回流数据与焦点延期) - [ ] Android/HBuilderX 真机或模拟器复核 -- [ ] 用户验收 -final result: passed (internal H5 candidate QA only; user review pending) +final result: user H5 visual review passed for implemented states; real slider, interface return data/focus and Android review pending --- # Design QA — A04 注册账号 - source visual truth path: A01 已选红色宗祠水墨视觉体系与 `docs/design/screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png` -- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A04-current-register-412x915.png` -- viewport: 412×915;运行 smoke 另查 320×568、360×800 -- state: 默认注册、空表单错误、协议错误、自定义行为验证、自定义反馈 +- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A04-current-register-v2-412x915.png` +- viewport: 412×915;运行 smoke 另查 320×568、360×640、360×800 +- state: 默认注册、空表单错误、协议错误、协议勾选、新版自定义反馈 ## Findings 没有遗留的 H5 P0/P1/P2 问题。 -- 视觉连续性:沿用 A01 的红色宗祠、水墨宣纸、独立 `brand-seal.png`、朱砂按钮与古金分隔,不再使用旧大厅与大面板。 +- 视觉连续性:Logo 已同步 A01 的 `184×221rpx` 槽位;标题分隔、朱砂主按钮和 Toast 已迁移到 A01 新资产体系,页面统一继承全局楷体,不再引用旧按钮、旧 Toast 图片层或旧验证面板。 - 表单层级:手机号、设置密码、确认密码三行保持清晰标签和输入提示;错误信息就近显示,不调用原生 UniApp 提示。 - 尺寸适配:三档宽高无横向溢出;320×568 按最新决定保持正常比例并自然纵向滚动。 -- 流程验证:A01 注册入口、三字段校验、协议校验、自定义验证层、自定义反馈以及返回登录均通过浏览器运行烟测。 +- 流程验证:A01 注册入口、三字段校验、协议校验、新版 Toast 反馈以及返回登录均通过浏览器运行烟测;完整本地校验后只提示“滑动验证待接口接入”。 ## Evidence limits -当前结论仅为 HBuilderX H5 + Chrome 内部 QA,通过不等于用户验收;Android 状态栏、安全区、软键盘、字体放大和返回手势仍需真机或模拟器复核。 +2026-07-16 用户已逐态确认当前可实现的 8 类 H5 视觉状态;真实滑动验证使用接口组件自带样式,注册成功/失败真实链路留到接口阶段审核。Android 状态栏、安全区、软键盘、字体放大和返回手势仍需真机或模拟器复核,因此 A04 继续保持 `[~]`,不冻结。 ## Implementation checklist - [x] A01 同系列视觉与真实资产 - [x] 三字段就近校验与协议页内错误 -- [x] 自定义验证层与反馈提示 -- [x] 320×568 自然滚动、360×800 与 412×915 无横向溢出 +- [x] A01 新 Logo、标题分隔、主按钮、Toast 与全局字体 +- [x] 移除非最终自定义验证层,保留滑动验证待接入反馈 +- [x] 320×568 自然滚动、360×640、360×800 与 412×915 无横向溢出 - [x] H5 静态契约与真实运行烟测 - [ ] Android/HBuilderX 真机或模拟器复核 -- [ ] 用户验收 +- [x] 用户确认当前 8 类 H5 视觉状态(真实滑动验证与接口结果态除外) -final result: passed (internal H5 candidate QA only; user review pending) +final result: user H5 visual review passed for implemented states; real slider, interface result states and Android review pending --- diff --git a/docs/design/G01_列表背景候选与换机重建.md b/docs/design/G01_列表背景候选与换机重建.md new file mode 100644 index 0000000..192ba7f --- /dev/null +++ b/docs/design/G01_列表背景候选与换机重建.md @@ -0,0 +1,72 @@ +# G01 列表背景候选与换机重建 + +> 日期:2026-07-16 +> 状态:三个母版已生成并通过本机确定性构建;尚未由用户选择,不得接入页面或写成已验收。 + +## 1. 为什么重做 + +现有 `footer-mountain-bamboo.png` 为 `750×360`,视觉主体集中在底部,上半部留白较大;页面又把它定位到整个内容末尾并使用 `scaleToFill` 拉伸,因此列表变长时山水被推到“添加家谱”下方。用户提出背景应更早出现,并进一步提出 G01 可能只滚动下方列表、上方当前家谱与快捷入口固定。 + +滚动结构暂不实施。新背景先作为列表滚动窗口候选资产保存,下一轮选择方向并确认滚动边界后再接入。 + +## 2. 稳定输入 + +三个母版位于: + +- `docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png` +- `docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png` +- `docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png` + +准确提示词、标题、处理模式、源尺寸和生成输出路径由 `design-pipeline/manifests/g01-background-candidates.json` 单一维护。A/B 使用纯 `#00ff00` 绿幕并提取透明叠加层;C 保留完整暖宣纸底。 + +## 3. 跨电脑重建 + +要求:Windows、Node.js 22+、npm、Python 3.12+。本机验证环境为 Node `24.15.0`、npm `11.12.1`、Python `3.14.6`、Pillow `12.3.0`。 + +在项目根目录执行: + +```powershell +python --version +node --version +npm.cmd --version +python -m venv design-pipeline/.venv +design-pipeline/.venv/Scripts/python.exe -m pip install --upgrade pip +design-pipeline/.venv/Scripts/python.exe -m pip install -r design-pipeline/requirements.txt +npm.cmd --prefix design-pipeline run build:g01-background-candidates +design-pipeline/.venv/Scripts/python.exe -m unittest design-pipeline/tests/test_build_g01_backgrounds.py -v +node --test design-pipeline/tests/g01-background-candidates.test.mjs +``` + +构建输出进入已忽略目录 `design-pipeline/generated/g01-background/`: + +- `g01-list-background-direction-a.png` +- `g01-list-background-direction-b.png` +- `g01-list-background-direction-c.png` +- `build-report.json` + +Node 入口只使用内置模块做路径与清单编排;图片解码、尺寸检查和像素处理由锁定版本的 Pillow 统一负责,因此 G01 候选构建不依赖已删除的 `node_modules`。项目其他使用 Sharp 的旧流水线仍需执行 `npm.cmd ci --prefix design-pipeline`。 + +## 4. 当前质量基线 + +本机输出: + +| 候选 | 模式 | 像素 | 字节 | SHA-256 | +| --- | --- | ---: | ---: | --- | +| A | chroma-key | 1024×1536 | 2,280,297 | `7b8a8abecc651b1bc4c589607780c8ff00ea0f13de018ca4b27a479b2a870fe4` | +| B | chroma-key | 1024×1536 | 1,890,556 | `3a0420eaaac9a2821da0f9a2ddbbbd6fb29b463fa6f41be7fa5c23948f782e2a` | +| C | opaque-paper | 1024×1536 | 2,407,477 | `f14aeed81046d2d5e7ae78b1e62f572689c38261a2cc043142bef9f70200dc5b` | + +三张输出均满足:可见绿幕残留 0、透明 RGB 0、尺寸错误 0、体积错误 0、sRGB 警告 0。本机连续构建两次的报告与文件 SHA-256 完全一致。若另一台电脑因 Python、Pillow 或压缩实现差异产生不同文件哈希,应先核对锁定依赖,再比较解码后的 RGBA 像素和质量报告,不能只凭压缩字节差异判定视觉失败。 + +## 5. 可复现边界 + +- 精确可复现:从仓库内保存的母版执行清单、Python 和 Node 流程,得到相同 RGBA 像素处理结果。 +- 方向可复现但非逐像素:在另一台电脑上使用清单中的提示词重新调用图像模型。 +- 不可依赖:Codex 本机生成目录、`tmp/`、`node_modules/`、`.venv/`、`generated/` 或未进入仓库的聊天图片。 + +## 6. 下一步 + +1. 用户比较 A/B/C 后选择一个方向,或提出组合要求。 +2. 选择后只保留一个正式母版合同,创建版本化 `static/assets/` 运行输出。 +3. 再讨论并实现:标题栏、当前家谱大卡片和快捷入口固定;从“我创建的”开始独立滚动;背景固定在列表滚动窗口背后。 +4. 完成 320×568、360×640、360×800、412×915 H5 截图和 Android/HBuilderX 复核。 diff --git a/docs/design/P00_页面结构与资产清单.md b/docs/design/P00_页面结构与资产清单.md index d100229..78f2643 100644 --- a/docs/design/P00_页面结构与资产清单.md +++ b/docs/design/P00_页面结构与资产清单.md @@ -1,6 +1,6 @@ # P00 页面结构与资产清单 -> 状态:54 条最终编号路由已落地;A01/A02/A04/A05/A06/G01 两种状态与 G03 `create|ancestor` 已验收;其余 47 条路由均已有 `[~]` 视觉候选和真实运行截图,等待用户逐页审核。 +> 状态覆盖:当前实际为 52 条活动路由、52 页全部 `[~]` 待审核、0 页冻结;A06 已封存且保留实现。页面状态以 `docs/交接记录.md` 与 `docs/验收规划.md` 为准。 > 目的:让页面、路由和资产都有唯一、可读、可审计的归属;本文件是 P-00 的执行清单。 ## 1. 页面命名规则 @@ -20,7 +20,10 @@ | 当前路由 | 当前可确认用途 | 目标文件 | 处理状态 | | --- | --- | --- | --- | -| `pages/auth/a01-entry` | A01 启动/登录引导 | `pages/auth/a01-entry.vue` | 已迁移;视觉冻结;完整按钮资产待补 | +| `pages/auth/a01-entry` | A01 启动/登录引导 | `pages/auth/a01-entry.vue` | 7 类非滑动验证视觉状态已获用户确认;真实滑动验证用接口组件自带样式,待接入审核;Android 复核前保持 `[~]` | +| `pages/auth/a04-register` | A04 注册账号 | `pages/auth/a04-register.vue` | 当前 8 类 H5 视觉状态已获用户确认;真实滑动验证、接口结果态与 Android/HBuilderX 待审核;保持 `[~]` | +| `pages/auth/a05-reset-password` | A05 重置密码 | `pages/auth/a05-reset-password.vue` | 当前 7 类 H5 视觉状态已获用户确认;接口回流手机号/焦点、真实滑动验证与 Android/HBuilderX 待审核;保持 `[~]` | +| (已从 `pages.json` 移除) | A06 登录阻断状态(封存) | `pages/auth/a06-auth-status.vue` | 用户确认当前样式没有问题,但当前流程暂时不用;源码、测试和证据保留,不计入活动验收统计,恢复时重新接入真实入口并审核 | | `pages/auth/a02-login` | A02 账号登录 | `pages/auth/a02-login.vue` | 已迁移;已验收,视觉冻结 | | `pages/genealogy/g01-my-genealogies` | G01 我的家谱 | `pages/genealogy/g01-my-genealogies.vue` | 已迁移;视觉冻结 | | `pages/genealogy/g03-create-genealogy` | G03 创建家谱/始祖录入 | `pages/genealogy/g03-create-genealogy.vue` | `step=create|ancestor`;已获用户验收,视觉冻结 | @@ -83,7 +86,7 @@ static/assets/ transparent/ ``` -当前已迁入的资产以第 4 节台账为准。A01 已使用 `a01-primary-button.png`、`a01-secondary-button.png` 两张完整按钮皮肤替换旧角标拼接实现;是否维持用户验收效果仍以运行时截图回归为准。 +当前已迁入的资产以第 4 节台账为准。A01 已引用版本化的卷轴祥云四件套 v3:主/次按钮与弹窗使用 `aspectFit`,Toast 使用固定切片的 `border-image`;文字和交互由代码承载。旧资产与 v2 试点资产保留,不覆盖、不删除。v3 已通过本机自动质量门和 H5 四尺寸检查,但尚未获得 Android 与用户验收。 ## 4. 当前资产台账 @@ -98,11 +101,17 @@ static/assets/ | `foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层 | A01、A02、A04、A05、A06 | 保留 | | `foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | 认证标题右侧云纹 | A01、A02、A04、A05、A06 | 保留 | | `foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 认证标题与正文结饰 | A01、A04、A05、A06 | 保留 | -| `foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | A01 朱砂主按钮完整皮肤 | A01、A02、A04、A05、A06 | 保留;A01、A02、A04、A05、A06 已验收 | -| `foundation/opaque/a01-secondary-button.png` | opaque / 1881×419 | A01 宣纸次按钮完整皮肤 | A01 | 保留;待运行截图回归 | -| `modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | 认证完整卷轴面板,含纸纹、双金线、角纹、云纹与底部山水 | A02、A04、A05、A06 | 保留;A02、A04、A05、A06 已验收 | -| `modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | 认证未同意协议图标 | A02、A04 协议确认行,叠在卷轴面板上 | 保留;A02、A04 已验收 | -| `modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | 认证已同意协议图标 | A02、A04 协议确认行,叠在卷轴面板上 | 保留;A02、A04 已验收 | +| `foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | 旧朱砂主按钮皮肤 | 其他仍实际引用的模块页面 | 暂时保留;A01、A04、A05、A06 已停止引用,其他页面需逐页迁移 | +| `foundation/opaque/a01-secondary-button.png` | opaque / 1881×419 | 旧宣纸次按钮皮肤 | 其他仍实际引用的页面 | 暂时保留;A01 可见按钮和 Toast 已停止引用,其他页面未逐页迁移 | +| `foundation/transparent/a01-primary-button-v2.png` | transparent / 1866×276 | A01 固定槽位主按钮历史试点 | 当前 A01 已停止引用 | 保留,不覆盖、不删除 | +| `foundation/transparent/a01-secondary-button-v2.png` | transparent / 1866×300 | A01 固定槽位次按钮历史试点 | 当前 A01 已停止引用 | 保留,不覆盖、不删除 | +| `foundation/transparent/a01-scroll-primary-v3.png` | transparent / 1866×276 | 卷轴祥云朱砂主按钮皮肤 | A01 登录、A04 注册、A05 重设、A06 恢复入口及弹层按钮 | A01/A04/A05 当前 H5 状态已获用户确认;A06 待逐态审核;均待 Android 复核 | +| `foundation/transparent/a01-scroll-secondary-v3.png` | transparent / 1866×300 | 卷轴祥云宣纸次按钮皮肤 | A01 微信按钮 | 用户 H5 视觉审核通过;待 Android 复核 | +| `foundation/transparent/a01-scroll-toast-v3.png` | transparent / 1770×246 | 卷轴祥云 Toast 可切片表面 | A01、A04、A05 自定义反馈 Toast | A01/A04 用户 H5 审核通过;A05 待逐态审核;均待 Android 复核 | +| `modules/auth/transparent/a01-scroll-dialog-v3.png` | transparent indexed PNG / 1860×1560 | 卷轴祥云对话容器 | A01 历史验证占位、A05 成功结果、A06 恢复说明 | A01 不得用于最终滑动验证;A05/A06 仅用于本地结果或说明候选 | +| `modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | 旧认证完整卷轴面板 | 当前认证页无引用 | 历史保留,不覆盖、不删除 | +| `modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | 认证未同意协议图标 | A01、A04 协议确认行 | 保留;A01/A04 用户 H5 审核通过 | +| `modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | 认证已同意协议图标 | A01、A04 协议确认行 | 保留;A01/A04 用户 H5 审核通过 | | `foundation/transparent/auth-login-outline.png` | transparent / 96×96 | 登录/状态图标 | A01、A06 | 保留 | | `foundation/transparent/auth-wechat.png` | transparent / 96×96 | 微信图标 | A01 | 保留 | | `foundation/transparent/brand-seal.png` | transparent / 240×288 | 品牌谱印 | A01、A02、A04、A05、A06、G06、`PageHeader` | 保留 | @@ -110,6 +119,9 @@ static/assets/ | `foundation/transparent/footer-mountain-bamboo.png` | transparent / 750×360 | 根页底部山水竹影 | G01、G06 | 保留 | | `foundation/transparent/notice.png` | transparent / 96×96 | 通知图标 | `PageHeader` | 保留 | | `foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回与列表箭头 | A02、A04、A05、A06、`GenealogyCard` | 保留 | +| `docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png` | candidate master / 1024×1536 | G01 列表背景 A,绿幕透明候选 | G01 待选 | 母版保留;未接入页面 | +| `docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png` | candidate master / 1024×1536 | G01 列表背景 B,绿幕透明候选 | G01 待选 | 母版保留;未接入页面 | +| `docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png` | candidate master / 1024×1536 | G01 列表背景 C,完整宣纸候选 | G01 待选 | 母版保留;未接入页面 | | `modules/genealogy/opaque/g01-empty-panel.png` | opaque / 1122×1402 | 无家谱空态完整宣纸卡,双金线、角饰、云纹与下沿山水 | G01 `state=empty` | 保留;由 ImageGen 生成,已获用户验收 | | `modules/genealogy/opaque/g03-create-flow-panel.png` | opaque / 1122×1500 | 完整宣纸任务面板,双金线、回纹角饰、云纹与下沿淡墨山水 | G03、G08、G11、G12、T01、T03 至 T06、T08、F/R/N/M 表单/详情/设置/状态 | 保留;由 ImageGen 生成,G03 已获用户验收,复用页待审 | | `modules/genealogy/opaque/g05-overview-surface.png` | opaque / 1122×1506 | 家谱总览完整视觉表面,含墨蓝谱名区、四宫格宣纸槽、金线近况分隔与底部访问说明面 | G05 `loading|ready|empty|error` | 保留;由 ImageGen 参考 G05 改前截图生成,页面只叠放真实文字和点击层;待集中审美确认 | @@ -142,14 +154,19 @@ static/assets/ 本轮已迁入 38 项在用资产,删除 24 项运行时零引用的旧副本。A01 的两张完整按钮皮肤与 G06 检索资产均由图像生成工具制作,不是设计图裁切;G06 不再使用大型检索面板,当前使用朱砂题签、宽版输入框与搜索按钮三张独立位图,页面只叠放真实品牌印、文字与交互控件。旧版设计记录中的历史路径不再是资产依据;后续只按本台账引用和维护。 -## 5. 图片属性规则 +## 5. 混合资产属性规则 + +完整生产、质量门和换机恢复方法以 `docs/design/设计资产生产流水线规范.md` 为准。P00 只登记最终资产归属,不重复维护算法和阈值。 | 类型 | 文件格式与内容 | 典型用途 | 页面使用规则 | | --- | --- | --- | --- | -| 不透明底图 | JPG 或无透明区域 PNG;包含完整底色、纹理、边框和四角 | 页面纸纹、朱砂页头、完整主/次按钮、卡片、导航 | 直接作为对应区域的完整视觉面,不再靠 CSS 补色或叠加边角 | -| 透明叠加图 | RGBA PNG;四周透明,主体边界干净 | Logo、图标、祥云、谱印、分隔纹、边框 | 明确记录叠在何种不透明底图上;不单独承担底色 | +| 代码原生 | Vue/SCSS,不产生图片 | 布局、文字、间距、普通分隔线、纯色和交互状态 | 不伪造复杂品牌装饰;普通内容保持正常布局流 | +| 固定比例位图 | JPG、PNG 或 WebP;比例与槽位一致 | 宗祠、水墨背景、固定比例复杂视觉面 | manifest 声明槽位和 `uniform-only`;禁止变形拉伸 | +| 透明叠加图 | RGBA PNG/WebP;外缘透明、主体边界干净 | Logo、图标、祥云、谱印、分隔纹 | manifest 声明 Alpha 与边缘策略;禁止白边、绿边 | +| 可伸缩装饰框 | 九宫格或经过验证的等价组件资产 | 多宽度按钮框、卡片框、弹窗框 | 必须先验证 H5 与 Android;四角和边线不得被整体拉伸 | +| 平铺纹理 | 可无缝重复的小图 | 宣纸、无方向纹理 | manifest 明确平铺/覆盖策略,不承担文字或边框 | -禁止项:整页截图、设计图局部裁切、半边边框、按钮角标拼接、CSS/SVG 绘制可见装饰、同一语义的多版本长期共存。 +禁止项:整页截图、设计图局部页面裁切、半边边框、按钮角标拼接、临时 CSS/SVG 伪造复杂品牌装饰、未声明的 `scaleToFill`/`fit: 'fill'`、同一语义的多版本长期共存。 ## 6. 资产清理登记规则 diff --git a/docs/design/assets/a01-vnext/A01-shared-scroll-skins-with-dialog-approved.png b/docs/design/assets/a01-vnext/A01-shared-scroll-skins-with-dialog-approved.png new file mode 100644 index 0000000..a94268f Binary files /dev/null and b/docs/design/assets/a01-vnext/A01-shared-scroll-skins-with-dialog-approved.png differ diff --git a/docs/design/assets/a01-vnext/A01-shared-skin-family-option-2-selected.png b/docs/design/assets/a01-vnext/A01-shared-skin-family-option-2-selected.png new file mode 100644 index 0000000..ac52be4 Binary files /dev/null and b/docs/design/assets/a01-vnext/A01-shared-skin-family-option-2-selected.png differ diff --git a/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png b/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png new file mode 100644 index 0000000..482b071 Binary files /dev/null and b/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png differ diff --git a/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3.png b/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3.png new file mode 100644 index 0000000..9819f6b Binary files /dev/null and b/docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3.png differ diff --git a/docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png b/docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png new file mode 100644 index 0000000..c82ad48 Binary files /dev/null and b/docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png differ diff --git a/docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png b/docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png new file mode 100644 index 0000000..fb9d105 Binary files /dev/null and b/docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png differ diff --git a/docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png b/docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png new file mode 100644 index 0000000..85ce476 Binary files /dev/null and b/docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png differ diff --git a/docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png b/docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png new file mode 100644 index 0000000..55b49eb Binary files /dev/null and b/docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png differ diff --git a/docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png b/docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png new file mode 100644 index 0000000..435e910 Binary files /dev/null and b/docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png differ diff --git a/docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png b/docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png new file mode 100644 index 0000000..f383f15 Binary files /dev/null and b/docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png differ diff --git a/docs/design/screens/handoff/2026-07-15/A01-selected-password-brand-seal-841x1870.png b/docs/design/screens/handoff/2026-07-15/A01-selected-password-brand-seal-841x1870.png deleted file mode 100644 index 8a204cc..0000000 Binary files a/docs/design/screens/handoff/2026-07-15/A01-selected-password-brand-seal-841x1870.png and /dev/null differ diff --git a/docs/design/screens/handoff/2026-07-15/A01-selected-sms-brand-seal-841x1870.png b/docs/design/screens/handoff/2026-07-15/A01-selected-sms-brand-seal-841x1870.png deleted file mode 100644 index e820828..0000000 Binary files a/docs/design/screens/handoff/2026-07-15/A01-selected-sms-brand-seal-841x1870.png and /dev/null differ diff --git a/docs/design/上传前文件清理审计_2026-07-16.md b/docs/design/上传前文件清理审计_2026-07-16.md new file mode 100644 index 0000000..a98eb55 --- /dev/null +++ b/docs/design/上传前文件清理审计_2026-07-16.md @@ -0,0 +1,28 @@ +# 上传前文件清理审计(2026-07-16) + +## 审计边界 + +- 只检查仓库长期交接截图、未跟踪二进制资产和已忽略的本地生成目录。 +- 不清理 `tmp/`、`runtime/`、测试、脚本、候选母版或用户已有修改。 +- 不修改仓库截图体积阈值,不执行任何 Git 暂存、提交、推送、重置或检出操作。 + +## 本轮已清理 + +以下两张旧 A01 全页截图没有被任何文档、索引、清单、代码或测试引用;逐张查看后确认其视觉方案已被 2026-07-16 的 A01 当前代表图和状态联系表替代,因此删除: + +- `docs/design/screens/handoff/2026-07-15/A01-selected-password-brand-seal-841x1870.png`(1,805,168 字节) +- `docs/design/screens/handoff/2026-07-15/A01-selected-sms-brand-seal-841x1870.png`(1,785,574 字节) + +合计减少 3,590,742 字节。没有删除其他历史 handoff 证据。 + +## 保留结论 + +- `docs/design/assets/g01-background/masters/` 下三张 G01 背景母版均被候选清单和换机重建文档引用,必须保留。 +- 当前其他未跟踪二进制资产均有文档、清单、代码或测试引用,不按“未跟踪”直接删除。 +- `design-pipeline/generated/`、`design-pipeline/.venv/`、`docs/design/screens/runtime/`、`tmp/` 等目录处于 Git 忽略状态,不会进入正常提交;本轮不做破坏性本地清理。 + +## 仍未解决的仓库合同 + +清理后,`docs/design/screens/handoff/` 仍有 50 个文件、29,887,097 字节,继续超过 35 个文件/20MB 的交接截图合同。剩余文件存在有效引用或属于长期证据,不能为了让测试变绿而擅自删除;后续仍需用户决定归档策略。 + +`tests/repository-handoff-size-contract.ps1` 还会把已忽略的 runtime 截图统计在内,因此必须同时如实报告脚本结果与 handoff 专项目录结果,不能放宽阈值掩盖问题。 diff --git a/docs/design/视觉设计交接手册.md b/docs/design/视觉设计交接手册.md index 3544c40..acc4d06 100644 --- a/docs/design/视觉设计交接手册.md +++ b/docs/design/视觉设计交接手册.md @@ -1,30 +1,31 @@ # 家谱 APP 视觉设计交接手册 -> **2026-07-15 状态覆盖说明:** 本手册中的截图方法、资产规则和验证流程继续有效;“54 条路由、7 页已验收/冻结”的旧状态不再有效。当前为 53 条最终路由、53 页全部重新待审核、0 页冻结,正在返工 A01。接管时先读 `docs/交接记录.md` 与 `docs/验收规划.md`。 +> **2026-07-16 状态覆盖说明:** 本手册中的截图方法、资产规则和验证流程继续有效;旧路由与验收数字不再有效。当前为 52 条活动路由、52 页全部 `[~]` 待审核、0 页冻结;A06 已从路由与活动验收队列封存但保留实现。接管时先读 `docs/交接记录.md` 与 `docs/验收规划.md`。 > 更新日期:2026-07-14 > 用途:在新电脑或交给新的 GPT 后,按本手册恢复当前工作;它只记录已发生的事实,不把待验收写成已完成。 ## 0. 给新 GPT 的可直接执行指令 -你接手的是一个 **uni-app Android 家谱项目的高质量视觉设计任务**,全程中文工作;它不是只改 CSS 的任务。先不修改代码,完整阅读:`AGENTS.md`、`docs/交接记录.md`、本手册、`docs/规划.md`、`docs/design/D1_安卓视觉规范与页面壳.md`、`docs/design/P00_页面结构与资产清单.md`、`docs/superpowers/plans/2026-07-13-product-design-visual-refinement.md`、`design-qa.md`。随后只向用户简洁复述当前停点和准备如何处理反馈。 +你接手的是一个 **uni-app Android 家谱项目的高质量视觉设计任务**,全程中文工作;它不是只改 CSS 的任务。先不修改代码,完整阅读:`AGENTS.md`、`docs/交接记录.md`、本手册、`docs/规划.md`、`docs/design/D1_安卓视觉规范与页面壳.md`、`docs/design/P00_页面结构与资产清单.md`、`docs/design/设计资产生产流水线规范.md`、`docs/superpowers/plans/2026-07-13-product-design-visual-refinement.md`、`design-qa.md`。随后只向用户简洁复述当前停点和准备如何处理反馈。 -必须保留已有未提交改动;不使用 worktree 或多代理;不执行 `git add`、`git commit`、`git push`、上传、`git reset` 或 `git checkout`。A01、A02、A04、A05、A06、G01、G03 的可见效果已获用户验收并冻结,不能以重构或顺手优化的名义改变。G07 是 G06 的页面内状态,不得重新建为独立页。 +必须保留已有未提交改动;不使用 worktree;不执行 `git add`、`git commit`、`git push`、上传、`git reset` 或 `git checkout`。旧阶段的 A01、A02、A04、A05、A06、G01、G03 验收/冻结记录只作历史证据;本轮 52 个活动页面全部重新待审核。A06 已封存,G07 是 G06 的页面内状态,不得重新建为独立页。 -用户已授权先完成全部页面候选、后集中审核。54 条最终路由均已覆盖:7 个已验收冻结,其余 47 个统一标 `[~]`;不得把候选写成用户已验收。下一阶段由用户按规划逐页勾选,接口只作功能布局参考,本轮没有对接。 +当前实际为 52 条活动路由,52 页全部标 `[~]`,0 页冻结;已有 14 个活动页面内部 H5 候选,A06 候选另行封存,均不得写成用户已正式验收。用户下一活动审核页是 G01;无人审核时下一施工页是 T07。接口只作功能布局参考,本轮没有对接。 新环境必须可用并实际调用:`product-design:index`、`product-design:audit`、`imagegen`、`superpowers:test-driven-development`、`superpowers:systematic-debugging`、`superpowers:verification-before-completion`。没有这些能力时,先安装/启用同等能力,不得假装已经完成设计审视或位图生成。 ## 1. 当前停点:先看这一节 -项目已有 54 条最终编号路由;T02 已收敛到 T01 同页状态。这不等于 54 个页面已完成视觉验收。 +项目实际有 52 条活动路由;A02 已合并进 A01,A06 已封存,T02 已合并进 T01且遗留占位文件已删除。`docs/验收规划.md` 中 52 个活动页面全部为 `[~]`,0 页已验收,0 页冻结。 -- 用户已经验收的视觉基准有七个:**A01 启动/登录引导**、**A02 账号登录**、**A04 注册账号**、**A05 重置密码**、**A06 登录状态**、**G01 我的家谱**、**G03 创建家谱**。它们的可见效果已冻结。 -- **A02 账号登录**已经完成资产化重做、功能回归和多尺寸截图,并于 2026-07-14 获用户审美验收。A03 已删除;A02 的短信 Tab 是唯一手机号验证码登录入口。A04、A05、A06 均于同日完成独立重做并获用户审美验收;G02 已按用户确认合并为 G01 的 `?state=empty`;G04 已合并为 G03 的 `?step=ancestor`;G07 已合并为 G06 的初始/结果/无结果状态。G06 三态候选截图和内部审视已经完成,但尚未获得用户视觉验收。 -- G05、G06、G08–G12、T01、T03–T08、F01–F10、R01–R11、N01–N02、M01–M10 已完成候选截图和审视;共享 `ModulePage.vue` 已升级为全位图高规格母版,不再是旧 CSS 过渡壳。 +- 已有 14 个活动页面内部 H5 候选:A01、A04、A05、G01、G03、G05、G06、G08、G09、G10、G11、G12、T01、T04。A06 的 H5 候选、源码、测试和证据已封存保留。 +- 这些候选、内部 smoke、GPT 审视和旧 QA 都不等于用户正式验收。 +- 用户下一活动审核页是 G01;无人审核时下一施工页是 T07 成员目录。 +- 基础版本只做浅色国风主题;深色和跟随系统延期。 - 不提交、不推送、不上传 Git;由用户自行处理版本库操作。 -下一位执行者先读取当前停点与已验收页面,然后按 `docs/规划.md` 的 `[~]` 清单协助用户逐页审核。默认只从 `docs/design/视觉证据索引.md` 打开一张代表图或一张状态联系表;多尺寸、before/after 与历史废稿仅在发现问题时展开。旧 `G06-no-panel-*` 图只能帮助理解迭代原因,不能用于当前验收。全部 47 个候选都等待用户逐页确认,不再按模块批量推进新页面。 +下一位执行者先读 `docs/交接记录.md` 和 `docs/验收规划.md`,再按 `[~]` 清单协助用户逐页审核。默认只打开一张代表图或一张复杂状态联系表;多尺寸、before/after 与历史废稿仅在发现问题时展开。 ## 1.1 迁移前必须保留的文件 @@ -40,6 +41,7 @@ | `docs/规划.md` | 54 条独立路由的范围、模块顺序和“完成”的定义。 | | `docs/design/D1_安卓视觉规范与页面壳.md` | Android 页面、资产、布局与中文注释的硬约束。 | | `docs/design/P00_页面结构与资产清单.md` | 路由、页面文件和资产台账的唯一清单。 | +| `docs/design/设计资产生产流水线规范.md` | 混合代码/位图落地、manifest、Node/Sharp/Python 分工、质量门和跨电脑恢复合同。 | | `docs/superpowers/plans/2026-07-13-product-design-visual-refinement.md` | 已批准的分模块视觉精修计划和验收顺序。 | | `docs/design/2026-07-13-A01-G01-A02-视觉审视.md` | A01/G01 视觉证据与旧 A02 的问题定位。 | | `docs/design/A02_账号登录_设计记录.md` | A02 当前实现、资产、截图和测试记录。 | @@ -59,14 +61,16 @@ 必须遵守: -1. 完整按钮、卡片、标题签、页面头图和有边框的装饰面,使用完整的**不透明位图资产**承载底色、纹理和边框。 -2. 透明 PNG 仅承担 Logo、图标、祥云、纹样等叠加元素;CSS 只承担布局、文字、状态与交互,不能伪造完整框、祥云、按钮或卡片。 +1. 最新生产方法以 `docs/design/设计资产生产流水线规范.md` 为准:代码承担布局、文字、间距、状态和普通结构;复杂水墨、品牌装饰和代码无法忠实表达的视觉使用真实位图。 +2. 固定比例完整位图、透明叠加资产和经 H5/Android 验证的九宫格必须在 manifest 中声明真实槽位、缩放和 Alpha 策略;不得默认使用 `scaleToFill` 或 Sharp `fit: 'fill'` 掩盖比例错误。 3. 不能把整张设计截图或局部截图裁进运行页面;运行资产必须是可复用的独立元素。 -4. 不要修改 A01/G01 已验收的可见布局、比例、配色或装饰。可读其实现和资产,但不能借“整理”改变效果。 +4. A01/G01 历史证据仍是品牌观察真源,但不等于本轮已验收或冻结;当前状态以 `docs/交接记录.md` 与 `docs/验收规划.md` 为准。 5. 不要用一个通用卡片母版覆盖所有模块。家谱、世系树、内容档案、人物记录、通知和设置必须在同一视觉语言下有各自的信息密度与层级。 ## 4. A02 的精确状态 +> 本节是 2026-07-14 的历史实现记录,不是当前路由或验收状态。当前 A02 已删除并合入 A01,本轮不得恢复 A02 路由;最新状态以 `docs/交接记录.md` 为准。 + ### 实现内容 - 路由与页面:`pages/auth/a02-login.vue` @@ -124,7 +128,9 @@ node scripts/capture-chrome-page.js ` ## 6. 后续执行顺序与每轮产物 -### 门禁 0:A02 与 A04 已通过 +### 历史门禁记录(本轮已失效) + +以下内容只解释旧阶段为何保留某些资产,不得据此把任何页面写成当前已验收或冻结。 1. A02 与 A04 均已通过;不因后续页面改动而触碰其可见效果。 2. 若用户提出明确调整:只在对应页面修正,先调整对应契约测试,再改页面/资产,重新截图复核。 diff --git a/docs/design/视觉证据索引.md b/docs/design/视觉证据索引.md index 000f4a9..6639405 100644 --- a/docs/design/视觉证据索引.md +++ b/docs/design/视觉证据索引.md @@ -5,7 +5,7 @@ - A01 当前密码/短信登录联系表(待审核,不代表通过):[824×915 真实运行代表图](screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png) - A04 当前注册页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A04-current-register-412x915.png) - A05 当前重设密码页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A05-current-reset-412x915.png) -- A06 当前风险限制页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A06-current-risk-412x915.png) +- A06 封存前风险限制页(样式无异议但不代表完整验收):[412×915 真实运行代表图](screens/handoff/2026-07-16/A06-current-risk-412x915.png) - G01 当前默认/空态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G01-current-default-empty-contact-720x800.png) - G06 当前搜索初始/结果/邀请码联系表(待审核,不代表通过):[1080×800 真实运行联系表](screens/handoff/2026-07-16/G06-current-initial-results-invite-contact-1080x800.png) - G08 当前搜索申请/邀请码直加联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G08-current-search-invite-contact-720x800.png) @@ -17,7 +17,7 @@ - G12 当前字辈列表/编辑联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G12-current-list-edit-contact-720x800.png) - T01 当前世系树/空态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/T01-current-tree-empty-contact-720x800.png) - T04 当前新增亲属表单/成功态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/T04-current-form-success-contact-720x800.png) -- 当前实际为 53 页全部重新待审核;下方旧“已验收”图片只作为历史视觉基础,不计入本轮完成状态。 +- 当前实际为 52 个活动页面全部 `[~]` 待审核;A06 证据封存保留,不计入活动验收统计。下方旧“已验收”图片只作为历史视觉基础,不计入本轮完成状态。 > 本仓库只保留低负担交接证据:每页一张代表图,复杂模块使用联系表。完整 before/after、多尺寸、历史废稿和全部运行截图保存在项目外本地归档,不进入 Git。 diff --git a/docs/design/设计资产生产流水线规范.md b/docs/design/设计资产生产流水线规范.md new file mode 100644 index 0000000..0ca1576 --- /dev/null +++ b/docs/design/设计资产生产流水线规范.md @@ -0,0 +1,374 @@ +# 家谱 APP 设计资产生产流水线规范 + +> 状态:已批准并完成 A01 卷轴祥云四件套 v3 的本机 H5 试点;仍待 Android/HBuilderX、跨电脑复现和用户正式验收。 +> 更新日期:2026-07-16。 +> 适用范围:本项目后续页面重设计、独立视觉资产生产、跨电脑恢复与质量审计。 +> 当前边界:manifest v2、Python/Pillow 质量门和 A01 主按钮、次按钮、Toast、验证弹窗四张 v3 资产已经落地并通过本机 H5 四尺寸检查;这不代表 Android 已复核、用户已验收或流水线可以推广到其他页面。 + +## 1. 目标 + +本项目采用“混合落地”方式,不把所有视觉都做成整张位图,也不把复杂国风装饰退化为临时 CSS 或手绘 SVG。 + +目标是: + +1. 设计稿中的布局、文字、间距、状态和普通结构由 Vue/SCSS 负责。 +2. 复杂水墨、宗祠、品牌印、专用图标和难以代码复现的装饰由真实位图资产负责。 +3. 可复用且需要伸缩的装饰框优先进入经过 Android/H5 验证的九宫格或组件化资产方案。 +4. Node.js、Sharp 与 Python 读取同一份 JSON manifest,禁止在脚本、页面和文档中重复维护尺寸规则。 +5. 自动化负责消除机械错误;真实运行截图和用户逐页审核仍是最终视觉判断来源。 + +流水线不能保证任意扁平设计图自动拆成一比一资产。要获得稳定质量,页面设计阶段必须同时明确组件边界、目标槽位、伸缩策略和独立资产任务。 + +## 2. 已有雏形与升级原则 + +仓库现有可复现雏形: + +- `design-pipeline/package.json` +- `design-pipeline/package-lock.json` +- `design-pipeline/manifests/a01.json` +- `design-pipeline/scripts/build-a01.mjs` +- `tests/a01-code-pipeline-contract.ps1` + +原 schema v1 雏形已经具备 Node.js、Sharp、JSON 清单、路径约束、尺寸/Alpha/体积检查和 A01 预览组合能力,但单独使用时仍不是合格的资产生产线,原因包括: + +- 对所有资产统一使用 `fit: 'fill'`,不能阻止比例拉伸。 +- manifest 只记录文件像素,不记录页面真实槽位和允许的缩放策略。 +- 没有检查白边、绿边、半透明脏边、预乘 Alpha、色彩空间和边缘污染。 +- 代码预览不是 uni-app 真实运行截图。 +- `source === output` 主要保证换机可验证,不能替代新资产的生产与质量处理。 + +升级时保留已有目录、Node/Sharp 依赖和清单入口,不另建一条互相冲突的流水线。新合同升级为 manifest v2;A01 试点通过前不得批量迁移其他页面。 + +2026-07-16 已实现的 v2 试点包括: + +- `design-pipeline/manifests/a01-buttons-v2.json`:尺寸、槽位、缩放、透明边、质量阈值和消费者的单一机器合同。 +- `design-pipeline/scripts/rebuild-a01-buttons.mjs` 与 `rebuild_a01_buttons.py`:Node 编排、Pillow 横向三段式离线重建。 +- `design-pipeline/scripts/verify-assets.mjs`、`verify-assets.py` 与 `asset_quality.py`:尺寸、Alpha 外圈、绿边、浅色半透明边、透明 RGB、体积和色彩声明检查。 +- `design-pipeline/requirements.txt`:锁定 `Pillow==12.3.0`。 +- Node/Python 单元测试、A01 静态契约、资产审计和四档 H5 运行时检查。 + +在双按钮试点基础上,用户选择“方案 2:卷轴祥云”并批准扩展为四件套 v3: + +- `design-pipeline/manifests/a01-scroll-skins-v3.json`:四件套源母图、输出槽位、缩放、透明边、体积和消费者的唯一机器合同。 +- `build-a01-scroll-skins.mjs`、`build_scroll_skins.py`:Node 编排与 Pillow 确定性构建;构建会清除外缘绿边、浅色半透明脏边和透明 RGB 残留。 +- 主/次按钮和弹窗按固定比例等比输出;Toast 输出可切片透明 PNG,由页面 `border-image` 承担自适应宽度。 +- 弹窗采用带透明度表的索引 PNG 控制体积,质量门读取真实透明通道,不通过放宽阈值换取通过。 + +旧 schema v1 仍保留给现有 A01 预览流程;不得把它的 `fit: 'fill'` 规则复制到 v2 固定比例资产。 + +## 3. 资产分类与唯一落地方式 + +每个设计元素在 manifest 中只能属于以下一种主要类型: + +| 类型 | `assetClass` | 正确落地 | 典型内容 | +| --- | --- | --- | --- | +| 代码原生 | `code-native` | Vue/SCSS | 布局、文字、间距、纯色、普通分隔线、状态与交互 | +| 固定比例位图 | `fixed-bitmap` | 精确槽位比例的 PNG/WebP,不允许变形 | 宗祠头图、复杂水墨、固定比例品牌视觉面 | +| 透明叠加资产 | `transparent-overlay` | 干净 RGBA PNG/WebP | Logo、印章、云纹、专用图标、结饰 | +| 可伸缩装饰框 | `nine-slice` | 经 H5/Android 验证的九宫格或等价组件 | 多宽度按钮框、卡片框、弹窗框 | +| 平铺纹理 | `tile-texture` | 明确的重复或覆盖策略 | 小尺寸宣纸纹、无方向噪声纹理 | + +禁止: + +- 把整页设计截图或局部页面截图直接裁入运行页面。 +- 用 emoji、文本符号、临时第三方 SVG、CSS 拼画代替缺失的品牌装饰或图标。 +- 同一资产既声明固定比例又在页面中随意拉伸。 +- 未经 manifest 授权使用 `scaleToFill` 或 Sharp `fit: 'fill'`。 +- 把候选图、审阅图、runtime 截图或 `generated/` 中间文件当成唯一正式源。 + +## 4. 单一 manifest v2 合同 + +manifest 是资产生产、页面接入和质量验证的唯一机器可读所有者。建议结构: + +```json +{ + "schemaVersion": 2, + "page": "A01", + "assets": [ + { + "id": "a01-primary-button-v2", + "assetClass": "fixed-bitmap", + "source": "static/assets/foundation/transparent/a01-primary-button-v2.png", + "output": "static/assets/foundation/transparent/a01-primary-button-v2.png", + "logicalSlot": { + "widthRpx": 622, + "heightRpx": 92 + }, + "outputPixels": { + "width": 1866, + "height": 276 + }, + "render": { + "scalePolicy": "uniform-only", + "uniMode": "aspectFit", + "allowDistortion": false + }, + "alpha": { + "required": true, + "transparentOutsideFrame": true, + "cornerMaxAlpha": 0 + }, + "edge": { + "forbidChromaResidue": true, + "forbidLightFringe": true, + "premultipliedAlphaCheck": true + }, + "quality": { + "maxBytes": 500000, + "symmetry": "horizontal", + "colorSpace": "sRGB" + }, + "consumers": [ + "pages/auth/a01-entry.vue" + ] + } + ] +} +``` + +正式实现时应为 schema v2 编写 JSON Schema 或等价的严格校验器。未知字段、缺失字段、越出工作区的路径、重复 ID 和未登记消费者都应尽早失败。 + +## 5. Node.js 与 Sharp 的职责 + +Node.js 是流水线编排层,继续复用当前 `design-pipeline/`: + +1. 解析并校验 manifest。 +2. 检查路径只能位于工作区内。 +3. 调用 Sharp 读取元数据、裁切、等比缩放、格式转换、压缩和预览组合。 +4. 根据 `scalePolicy` 选择算法;`uniform-only` 禁止 `fit: 'fill'`。 +5. 扫描 Vue/SCSS 中的资产引用、`mode` 和显示槽位。 +6. 检查资源比例与页面逻辑槽位比例,默认容差不超过 0.5%。 +7. 禁止页面引用未进入 manifest 的新视觉资产。 +8. 生成包含 SHA-256、尺寸、字节数、Alpha、消费者和检查结果的构建报告。 +9. 使用现有 Chrome CDP 9222 对真实 H5 页面做尺寸、资源加载和局部截图验证。 + +Node/Sharp 不负责艺术判断,也不把代码组合预览当作用户验收证据。 + +## 6. Python 的职责 + +Python 是像素质量层,建议使用 Pillow;只有 Pillow 无法稳定完成的边缘分析再引入 OpenCV,避免不必要的重依赖。 + +职责: + +1. 验证 PNG/WebP 解码、实际尺寸、通道数和 sRGB 色彩空间。 +2. 检查透明角、外框外 Alpha、可见内容边界和空白边距。 +3. 检测绿幕残留、异常高亮边缘和半透明脏边。 +4. 对透明边缘执行去污染与正确的 Alpha 处理,避免 Android 缩放后出现白边或绿边。 +5. 检查预乘 Alpha 风险;完全透明像素不得保留会渗出的异常 RGB。 +6. 对要求对称的按钮和装饰框检查左右差异。 +7. 生成局部边缘放大图、资产联系表和机器可读报告。 +8. 质量不达标时返回非零退出码,禁止继续接入页面。 + +白边检测不能简单地“禁止白色”,因为次按钮内部可能本来就是暖白宣纸。检查必须只针对外框外区域、半透明边界和 manifest 声明的允许色板。 + +## 7. 资产生产流程 + +### 7.1 设计阶段 + +设计或 ImageGen 输出页面方向时,同时登记: + +- 页面逻辑尺寸和目标视口。 +- 组件边界与逻辑槽位。 +- 哪些内容必须由代码渲染。 +- 哪些内容需要独立透明资产。 +- 哪些装饰允许伸缩以及不可伸缩安全区。 +- 字体、字号、颜色和状态。 + +不接受只有一张扁平页面图而没有资产任务单的“最终设计”。 + +### 7.2 候选生产 + +1. 只有缺少完整位图资产时使用 ImageGen。 +2. 输入图必须明确角色:编辑目标、风格参考或组合参考。 +3. 生成内容不包含应由代码渲染的按钮文字、表单文字和状态文案。 +4. 中间候选进入忽略目录,不覆盖正式资产。 +5. 选择一个母图后再进入确定性处理;候选图不长期堆入仓库。 + +ImageGen 的艺术输出不能保证跨电脑逐像素复现。跨电脑可复现的是“已选择母图之后的处理、验证和页面接入”。需要长期重建的唯一母图必须进入受控正式路径或由最终资产本身承担稳定输入。 + +### 7.3 确定性处理 + +1. 按 manifest 裁切或补透明边距。 +2. 以目标槽位比例导出 2×/3×文件。 +3. 清理 Alpha、白边、绿边和异常 RGB。 +4. 转为 sRGB,使用高质量等比缩放。 +5. 输出新的版本化文件,不直接覆盖当前正式资产。 +6. Python 质量门通过后,才允许更新页面引用。 + +### 7.4 页面接入 + +1. 先写或调整资产合同并看到预期失败。 +2. 页面使用 manifest 指定的渲染模式。 +3. 文字、图标和交互状态保持独立可访问节点。 +4. 只在真正需要叠层的地方使用定位;普通内容保持正常布局流。 +5. 禁止为了匹配截图而恢复错误比例或无依据的定位。 + +### 7.5 运行验证 + +最低验证层级: + +1. manifest/schema 合同。 +2. Python 像素质量检查。 +3. Node/Sharp 构建和引用检查。 +4. H5 Chrome 真实运行:320×568、360×640、360×800、412×915。 +5. 一张代表图或一张复杂状态联系表。 +6. Android/HBuilderX 真机或模拟器复核。 +7. 用户明确逐页验收。 + +H5、自动截图差异、内部 smoke 和 GPT 审视都不能替代第 6、7 层。 + +## 8. 质量门槛 + +### 8.1 通用门槛 + +- 输出像素必须与 manifest 完全一致。 +- 固定比例资产与逻辑槽位比例误差默认不超过 0.5%。 +- `uniform-only` 资产不得出现非等比缩放。 +- PNG 声明透明时必须真正包含 Alpha 通道。 +- 声明透明角时,指定角点 Alpha 必须为 0。 +- 禁止绿幕残留和未授权的浅色外缘。 +- 文件必须为 sRGB;不得依赖另一台电脑上的专有字体、PSD 或本机临时目录。 +- 体积超过 `maxBytes` 时失败,不通过降低清晰度或删除证据掩盖。 + +### 8.2 固定按钮门槛 + +- 图片中不包含按钮文字和微信图标。 +- 外框之外透明,内部视觉面完整。 +- 四角、双边和装饰连续,不得出现切口或拼接缝。 +- 3×输出缩小到真实槽位后,金线仍连续且不过度发虚。 +- 按钮点击区域由代码控制,不由图片透明区决定。 + +### 8.3 自动差异的边界 + +截图差异只比较同视口、同状态和同字体环境。字体抗锯齿、WebView 与 Chrome 的细小差异不能用单一全屏像素阈值判死刑;应优先比较资产局部裁图、尺寸、边缘和结构,再由人工判断整体视觉。 + +## 9. A01 卷轴祥云资产试点 + +流水线首先用两张 v2 按钮验证固定槽位,随后按用户选定方向升级为四件套 v3。当前 A01 实际引用: + +- `a01-scroll-primary-v3.png`:`1866×276px`,登录主按钮。 +- `a01-scroll-secondary-v3.png`:`1866×300px`,微信次按钮。 +- `a01-scroll-toast-v3.png`:`1770×246px`,反馈 Toast 九宫格/切片表面。 +- `a01-scroll-dialog-v3.png`:`1860×1560px`,安全验证弹窗表面。 + +当前 `a01-primary-button.png`、`a01-secondary-button.png` 的原始比例与页面槽位不一致,且次按钮外缘存在高亮白/浅绿色像素,不能作为新流水线质量样板。 + +本机 H5 试点结果: + +- 四张 v3 输出通过尺寸、Alpha 外圈、透明 RGB、绿边、浅色半透明边、体积和色彩声明检查;旧资产和 v2 试点资产原样保留。 +- A01 主/次按钮与弹窗使用 v3 路径和 `aspectFit`;Toast 使用 v3 切片资产;所有文字、微信图标和交互仍由页面代码独立承载。 +- 自动质量门与 `320×568`、`360×640`、`360×800`、`412×915` H5 运行检查通过。 +- 412×915 默认态、Toast 和验证弹窗真实运行截图未见原来的外缘白/浅绿边、角饰断裂或整体比例拉伸;仍需 Android/HBuilderX 与用户确认。 + +试点规则: + +1. 保留当前资产,不覆盖、不删除。 +2. 新资产使用版本化路径。 +3. 先通过 Python/Node 自动质量门,再接入 A01。 +4. 接入后只保存一张 412×915 代表图;四尺寸可以自动检查但不连续保存大量相似截图。 +5. H5 通过只能写“内部候选”;Android 和用户确认之前,A01 仍为 `[~]`。 +6. 只有用户确认试点资产质量达到标准,才把 manifest v2 和混合流水线推广到其他页面。 + +## 10. 跨电脑环境合同 + +### G01 列表背景候选扩展 + +2026-07-16 已把混合流水线扩展到 G01 背景候选,但尚未推广为正式运行资产。三个 ImageGen 母版、准确提示和处理模式保存在 `docs/design/assets/g01-background/masters/` 与 `design-pipeline/manifests/g01-background-candidates.json`;`build-g01-backgrounds.mjs` 使用 Node 内置模块编排,`build_g01_backgrounds.py` 使用锁定的 Pillow 完成绿幕转 Alpha、去绿边、透明 RGB 清理、sRGB 写入和确定性 PNG 输出。 + +执行 `npm.cmd --prefix design-pipeline run build:g01-background-candidates` 可在另一台电脑从仓库母版重建三个候选。模型重新生成不能保证逐像素一致;精确重建必须使用仓库内母版。完整步骤、当前哈希和质量基线见 `docs/design/G01_列表背景候选与换机重建.md`。 + +### 10.1 当前已可运行的 Node 雏形 + +新电脑需要 Windows、Node.js 22 或更高版本、npm、HBuilderX、Chrome 和项目完整工作区。当前 Node 24 已验证可用。 + +```powershell +node --version +npm --version +npm.cmd ci --prefix design-pipeline +npm.cmd --prefix design-pipeline run build:a01 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1 +``` + +这些命令只证明现有 schema v1 雏形可运行,不证明新质量门已经实现。 + +### 10.2 manifest v2 的 Python 环境 + +`design-pipeline/` 已提供锁定依赖、统一 Node 入口和 Python 质量脚本;不得要求接管者凭经验安装不确定版本。当前结构: + +```text +design-pipeline/ + package.json + package-lock.json + requirements.txt + manifests/ + scripts/ + rebuild-a01-buttons.mjs + rebuild_a01_buttons.py + verify-assets.mjs + verify-assets.py + generated/ # 忽略,可再生成 +``` + +恢复和验证命令: + +```powershell +python --version +python -m venv design-pipeline/.venv +design-pipeline/.venv/Scripts/python.exe -m pip install --upgrade pip +design-pipeline/.venv/Scripts/python.exe -m pip install -r design-pipeline/requirements.txt +npm.cmd ci --prefix design-pipeline +npm.cmd --prefix design-pipeline run test:v2 +npm.cmd --prefix design-pipeline run validate:a01-buttons +npm.cmd --prefix design-pipeline run rebuild:a01-buttons +npm.cmd --prefix design-pipeline run verify:assets +npm.cmd --prefix design-pipeline run validate:a01-scroll-skins +npm.cmd --prefix design-pipeline run build:a01-scroll-skins +npm.cmd --prefix design-pipeline run verify:a01-scroll-skins +design-pipeline/.venv/Scripts/python.exe -m unittest discover -s design-pipeline/tests -p "test_*.py" -v +``` + +Node 入口优先使用 `PYTHON` 环境变量,其次自动使用 `design-pipeline/.venv/Scripts/python.exe`,最后才回退到系统 `python`。本机已按以上依赖完成验证;跨电脑仍必须真实执行并保存输出,不能只因文件齐全就写成换机复现通过。 + +### 10.3 可迁移文件 + +至少保留: + +- `design-pipeline/package.json` +- `design-pipeline/package-lock.json` +- 实施后新增的 Python 锁定依赖文件 +- manifest、JSON Schema、构建/质量脚本和对应测试 +- manifest 引用的正式资产 +- 页面源码和真实截图工具 +- 本规范、`docs/交接记录.md` 与 P00 资产台账 + +不得依赖: + +- `node_modules/` +- Python 虚拟环境 +- `design-pipeline/generated/` +- `tmp/` +- 本机 Chrome 用户目录 +- Photoshop/PSD +- 未进入仓库的候选图或 runtime 截图 + +## 11. 失败处理 + +- 依赖安装失败:停止并报告缺失环境,不绕过锁文件。 +- manifest 不合法:修正单一数据源,不在页面里增加例外。 +- 比例不匹配:回到目标槽位或重新导出,禁止 `fill` 掩盖。 +- 白边/绿边:回到 Alpha 去污染步骤,禁止用页面背景色遮盖。 +- H5 与组合预览不同:以真实 H5 为准并查明差异。 +- H5 与 Android 不同:不得冻结资产,记录 WebView/字体/缩放差异后修正。 +- 自动检查通过但用户认为不好看:状态仍为待审核,视觉判断以用户最新决定为准。 + +## 12. 推广条件 + +以下条件全部满足后,才能从 A01 推广: + +1. A01 四张 v3 卷轴祥云资产通过 manifest、Python、Node 和四尺寸检查。 +2. 412×915 真实 H5 代表图没有白边、绿边、拉伸或模糊。 +3. Android/HBuilderX 已复核。 +4. 用户明确确认四件套资产质量和 A01 整页达到标准。 +5. 新电脑按文档能够从锁文件恢复环境并得到一致的质量报告。 + +当前仅满足第 1、2 项;第 3、4、5 项尚未满足。因此本规范和 A01 试点仍不是整项目推广或用户验收完成证明。 diff --git a/docs/superpowers/plans/2026-07-16-a01-brand-seal-size.md b/docs/superpowers/plans/2026-07-16-a01-brand-seal-size.md new file mode 100644 index 0000000..feaf6e9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-a01-brand-seal-size.md @@ -0,0 +1,52 @@ +# A01 品牌 Logo 尺寸微调实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 A01 顶部品牌 Logo 精确缩小到 `184×221rpx`,其他布局保持不变。 + +**Architecture:** 继续使用现有 `brand-seal.png` 和绝对定位叠层,只调整页面内唯一的尺寸所有者 `.brand-seal`。静态契约负责防止尺寸回退,真实 H5 截图负责视觉判断。 + +**Tech Stack:** uni-app Vue、SCSS、PowerShell 契约测试、Chrome CDP 截图与运行 smoke。 + +## Global Constraints + +- 只修改 A01 Logo 尺寸和对应契约。 +- 不使用 worktree,不执行 Git 暂存、提交、推送、重置或清理。 +- 不把 H5 截图写成 Android 或用户整页验收。 + +--- + +### Task 1: 锁定并实现 Logo 尺寸 + +**Files:** +- Modify: `tests/a01-a02-ui-contract.ps1` +- Modify: `pages/auth/a01-entry.vue` + +**Interfaces:** +- Consumes: `.brand-seal` 现有样式规则和 `brand-seal.png`。 +- Produces: 精确的 `184×221rpx` H5 显示槽位。 + +- [ ] **Step 1: 写入失败契约** + +在测试中解析 `.brand-seal`,要求 `width: 184rpx;`、`height: 221rpx;`、`top: 64rpx;` 和 `left: 50%;`。 + +- [ ] **Step 2: 确认 RED** + +运行:`powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1` + +预期:因页面仍为 `220×264rpx` 而失败。 + +- [ ] **Step 3: 最小实现** + +将 `.brand-seal` 的宽高改为: + +```scss +width: 184rpx; +height: 221rpx; +``` + +- [ ] **Step 4: 验证 GREEN 与真实运行** + +运行 A01 静态合同、四档运行 smoke、412×915 截图和 `git diff --check`;人工确认 Logo 无拉伸、裁切或新的层级问题。 + +项目禁止 Git 操作,因此本计划不包含提交步骤。 diff --git a/docs/superpowers/plans/2026-07-16-a01-scroll-skins-v3.md b/docs/superpowers/plans/2026-07-16-a01-scroll-skins-v3.md new file mode 100644 index 0000000..50654d4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-a01-scroll-skins-v3.md @@ -0,0 +1,197 @@ +# A01 Scroll Skins V3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. The user forbids subagents, worktrees, commits, and all Git mutation commands, so execution stays inline in the current worktree and every commit step is intentionally omitted. + +**Goal:** Replace A01's current button, Toast, and verification-panel visuals with the user-approved option-2 scroll-and-cloud family while preserving live text, exact slot ratios, interaction behavior, and old assets. + +**Architecture:** Keep the approved board as the immutable visual target. Generate one clean master per surface, then use the existing Node/Pillow pipeline to remove presentation backgrounds, preserve fixed decorative caps/corners, stretch only safe centers, and emit exact versioned outputs. A01 consumes the new outputs only after manifest and pixel tests pass; H5 comparison uses the approved board plus real runtime screenshots. + +**Tech Stack:** uni-app/Vue, SCSS, Node.js 24, native `node:test`, Python 3.14, Pillow 12.3.0, PowerShell contracts, Chrome CDP 9222, built-in ImageGen. + +## Global Constraints + +- Scope is A01 only; do not edit other page implementations. +- Preserve `STKaiti`, `KaiTi`, live button copy, WeChat icon, verification copy, interactions, and current logical slots. +- Keep all old assets; write only versioned v3 outputs. +- Do not use `scaleToFill` to hide a ratio mismatch. +- Master and output assets contain no text, icons, logo, white halo, green fringe, or presentation shadow. +- A01 remains `[~]`; H5 output is internal evidence only until Android/HBuilderX and user review. +- Do not run worktree, subagent, `git add`, `commit`, `push`, `reset`, `checkout`, upload, delete, or cleanup commands. + +--- + +### Task 1: Preserve Exact Individual Masters + +**Files:** +- Reference: `docs/design/assets/a01-vnext/A01-shared-scroll-skins-with-dialog-approved.png` +- Create: `docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png` +- Create: `docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png` +- Create: `docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png` +- Create: `docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3.png` + +**Interfaces:** +- Consumes: the approved four-component board and the measured slots from the design spec. +- Produces: four committed-quality visual masters with flat chroma presentation backgrounds, no text, and quiet safe centers. + +- [ ] **Step 1: Generate the four masters independently** + +Use ImageGen once per master. Attach the approved board every time. Require the exact family, one isolated component, a flat `#00ff00` background for deterministic extraction, no external shadow, and these target ratios: `622:92`, `622:100`, `590:82`, `620:520`. + +- [ ] **Step 2: Inspect every master** + +Open all four with `view_image`. Reject any master with text, asymmetric clouds, cropped scroll rods, missing gold lines, landscape above the dialog's lower 22%, or decoration crossing the live-content safe center. + +- [ ] **Step 3: Save only the approved individual masters** + +Copy each generated result to the exact master path without overwriting an existing file. Keep ImageGen originals untouched. + +### Task 2: Declare the V3 Machine Contract + +**Files:** +- Create: `design-pipeline/manifests/a01-scroll-skins-v3.json` +- Modify: `design-pipeline/tests/manifest-v2.test.mjs` +- Modify: `design-pipeline/package.json` + +**Interfaces:** +- Consumes: four master paths from Task 1. +- Produces: manifest assets `a01-scroll-primary-v3`, `a01-scroll-secondary-v3`, `a01-scroll-toast-v3`, and `a01-scroll-dialog-v3`. + +- [ ] **Step 1: Write failing manifest tests** + +Add assertions that the manifest declares exactly four unique outputs, exact logical/output dimensions, `uniform-only` for fixed button/dialog outputs, `nine-slice` for Toast, and A01 consumer selectors. + +- [ ] **Step 2: Run RED** + +Run `npm.cmd --prefix design-pipeline run test:v2`. Expected: failure because `a01-scroll-skins-v3.json` does not exist. + +- [ ] **Step 3: Add the manifest and scripts** + +Declare outputs: + +```text +static/assets/foundation/transparent/a01-scroll-primary-v3.png 1866×276 +static/assets/foundation/transparent/a01-scroll-secondary-v3.png 1866×300 +static/assets/foundation/transparent/a01-scroll-toast-v3.png 1770×246 +static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png 1860×1560 +``` + +Add `build:a01-scroll-skins` and `verify:a01-scroll-skins` package scripts. All paths must remain inside the workspace. + +- [ ] **Step 4: Run GREEN** + +Run the Node tests and manifest validator. Expected: all tests pass and `MANIFEST-V2 PASS` names the v3 manifest. + +### Task 3: Build Exact Clean Outputs + +**Files:** +- Create: `design-pipeline/tests/test_build_scroll_skins.py` +- Create: `design-pipeline/scripts/build_scroll_skins.py` +- Create: `design-pipeline/scripts/build-a01-scroll-skins.mjs` +- Modify: `design-pipeline/scripts/asset_quality.py` + +**Interfaces:** +- Produces: `remove_chroma_background(image, key, tolerance)`, `stretch_safe_center(image, output_size, insets)`, and `build_manifest(manifest, workspace)`. + +- [ ] **Step 1: Write failing synthetic tests** + +Tests must prove that chroma pixels become transparent black, decorative caps/corners remain pixel-identical after resizing, only the declared center stretches, exact output dimensions are returned, and a dialog with `alpha.required=false` remains opaque. + +- [ ] **Step 2: Run RED** + +Run the Python unittest discovery command. Expected: import failure for `build_scroll_skins`. + +- [ ] **Step 3: Implement minimal Pillow processing** + +Use RGBA conversion, chroma-distance Alpha removal, bounding-box trim, manifest insets, Pillow LANCZOS only for target-height normalization, center-only stretching, transparent-black cleanup, sRGB chunk writing, and optimized PNG output. + +- [ ] **Step 4: Run GREEN and build actual assets** + +Run Python tests, `npm.cmd --prefix design-pipeline run build:a01-scroll-skins`, and the quality verifier. Expected for every asset: exact dimensions, zero visible chroma residue, zero light fringe, zero transparent RGB, and no manifest errors. + +- [ ] **Step 5: Inspect all four outputs** + +Use `view_image`; reject seams, cropped rods, distorted clouds, weak gold contrast, center-knot drift, or dialog landscape entering the content region. + +### Task 4: Integrate A01 Test-First + +**Files:** +- Modify: `tests/a01-a02-ui-contract.ps1` +- Modify: `tests/a01-asset-alpha-audit.ps1` +- Modify: `tests/a01-responsive-runtime-smoke.js` +- Modify: `pages/auth/a01-entry.vue` + +**Interfaces:** +- Consumes: four verified v3 outputs. +- Produces: A01 using v3 visuals without changing behavior or live content. + +- [ ] **Step 1: Extend contracts before page changes** + +Require the four v3 paths, exact sizes, no old A01 button/Toast/dialog references in A01, no `scaleToFill` on these skins, and the approved runtime selectors. + +- [ ] **Step 2: Run RED** + +Run both PowerShell contracts. Expected: failure because A01 still uses v2 buttons and the old dialog/code-native Toast. + +- [ ] **Step 3: Make the minimal A01 change** + +Replace only the decorative skin layers. Preserve copy, icon, state, click handlers, normal-flow layout, overlay positioning, and slot dimensions. Use `aspectFit` for exact button/dialog assets; use the Toast v3 asset through its declared adaptive rendering strategy. + +- [ ] **Step 4: Run GREEN** + +Run the PowerShell contracts and Python quality audit. Expected: pass. + +### Task 5: Real Runtime and Visual Comparison + +**Files:** +- Modify: `scripts/capture-chrome-page.js` +- Create ignored evidence under: `tmp/a01-scroll-skins-v3-*.png` +- Modify: `design-qa.md` + +**Interfaces:** +- Produces: password, Toast, and verification-dialog runtime evidence at `412×915`, plus four-viewport structural verification. + +- [ ] **Step 1: Add deterministic capture states** + +The capture tool must support `password-tab`, `invalid-login`, and `verification-dialog` without arbitrary click timing. Runtime smoke must assert all four v3 assets load at expected natural dimensions. + +- [ ] **Step 2: Run four viewports** + +Run `node tests/a01-responsive-runtime-smoke.js`. Expected: pass at `320×568`, `360×640`, `360×800`, and `412×915`, with natural scrolling at 320×568 and no horizontal overflow. + +- [ ] **Step 3: Capture and compare** + +Capture the three meaningful A01 states. Compare the approved visual board and runtime evidence together. Fix P0/P1/P2 mismatches; do not loop on P3 polish. + +- [ ] **Step 4: Update QA truthfully** + +Record the exact source and runtime images in `design-qa.md`. The result may say H5 passed only; Android and user acceptance remain pending. + +### Task 6: Documentation and Final Verification + +**Files:** +- Modify: `docs/design/设计资产生产流水线规范.md` +- Modify: `docs/design/P00_页面结构与资产清单.md` +- Modify: `docs/交接记录.md` + +- [ ] **Step 1: Record implemented facts** + +Document the selected visual source, four v3 outputs, build/verify commands, A01-only scope, preserved old assets, and remaining Android/user gates. + +- [ ] **Step 2: Run the focused verification set** + +```powershell +npm.cmd --prefix design-pipeline run test:v2 +npm.cmd --prefix design-pipeline run build:a01-scroll-skins +npm.cmd --prefix design-pipeline run verify:a01-scroll-skins +design-pipeline/.venv/Scripts/python.exe -m unittest discover -s design-pipeline/tests -p "test_*.py" -v +powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-asset-alpha-audit.ps1 +node tests/a01-responsive-runtime-smoke.js +git diff --check +``` + +Expected: all focused checks pass; `git diff --check` has no whitespace error. CRLF notices alone are not failures. + +- [ ] **Step 3: Report remaining gates** + +State explicitly: H5 internal evidence completed or blocked, Android/HBuilderX pending, user page acceptance pending, A01 remains `[~]`, no Git mutation commands executed. diff --git a/docs/superpowers/plans/2026-07-16-a04-a01-visual-system-migration.md b/docs/superpowers/plans/2026-07-16-a04-a01-visual-system-migration.md new file mode 100644 index 0000000..b1418a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-a04-a01-visual-system-migration.md @@ -0,0 +1,117 @@ +# A04 同步 A01 新视觉体系实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 A04 页面内旧认证视觉全部替换为 A01 已审核的新体系,并移除非最终的自定义滑动验证面板。 + +**Architecture:** A04 继续独立拥有注册字段和校验逻辑,只复用 A01 已生产的位图资产与全局字体合同。主按钮按固定比例等比显示,Toast 使用同一张可切片资产;真实滑动验证由后续接口组件负责,当前有效提交只显示待接入反馈。 + +**Tech Stack:** uni-app Vue 3、SCSS、PowerShell 静态契约、Chrome CDP 运行 smoke。 + +## Global Constraints + +- A04 Logo 必须为 `184×221rpx`、`top: 64rpx`、水平居中。 +- 禁止 A04 引用 `a01-primary-button.png`、`a01-secondary-button.png`、`a02-login-panel.png` 或局部 `STSong/SimSun`。 +- 注册主按钮使用 `a01-scroll-primary-v3.png` 和 `aspectFit`;Toast 使用 `a01-scroll-toast-v3.png` 的 `border-image`。 +- 协议图标、返回箭头、宗祠底板保留。 +- 不使用 worktree,不执行 Git 暂存、提交、推送、重置或清理。 +- 不修改 A01 或其他页面,不对接接口,不把 H5 写成 Android 或用户整页验收。 + +--- + +### Task 1: 用契约锁定 A04 新视觉和验证边界 + +**Files:** +- Modify: `tests/a04-registration-contract.ps1` +- Modify: `tests/a04-registration-runtime-smoke.js` + +**Interfaces:** +- Consumes: A04 当前页面结构与 A01 v3 资产路径。 +- Produces: 旧资产禁止、Logo 精确尺寸、Toast 切片和有效提交反馈的回归合同。 + +- [ ] **Step 1: 写入失败静态契约** + +要求 A04 包含 `a01-vnext-divider-v1.png`、`a01-scroll-primary-v3.png`、`a01-scroll-toast-v3.png`、`width: 184rpx;`、`height: 221rpx;`、`border-image-slice:` 和“滑动验证待接口接入”;禁止三个旧资产、`verificationVisible`、`verification-layer`、`feedback-toast__skin` 与 `font-family:`。 + +- [ ] **Step 2: 更新运行 smoke 的期望** + +有效表单提交后等待 `.feedback-toast`,断言文案为“滑动验证待接口接入”,并断言页面不存在 `.verification-layer`;四档尺寸改为 `320×568`、`360×640`、`360×800`、`412×915`。 + +- [ ] **Step 3: 运行测试确认 RED** + +运行: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a04-registration-contract.ps1 +``` + +预期:因 A04 仍引用旧主按钮和旧验证面板而失败。 + +--- + +### Task 2: 最小迁移 A04 页面 + +**Files:** +- Modify: `pages/auth/a04-register.vue` + +**Interfaces:** +- Consumes: `a01-vnext-divider-v1.png`、`a01-scroll-primary-v3.png`、`a01-scroll-toast-v3.png` 与全局字体。 +- Produces: A04 新默认态、错误态、协议态和滑动验证待接入反馈。 + +- [ ] **Step 1: 替换模板资产** + +将标题分隔和主按钮改为新资产与 `aspectFit`,删除验证弹层模板及 Toast 内部拉伸图片。 + +- [ ] **Step 2: 收敛脚本状态** + +删除 `verificationVisible`、`closeVerification`、`confirmVerification`;有效提交改为: + +```js +showFeedback('滑动验证待接口接入') +``` + +- [ ] **Step 3: 同步样式** + +将 `.brand-seal` 改为 `184×221rpx`;移除所有局部 `font-family`;主按钮保持现有点击槽但等比承载 v3 皮肤;Toast 使用: + +```scss +border: 16rpx solid transparent; +border-width: 16rpx 74rpx; +border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png'); +border-image-slice: 58 280 fill; +border-image-width: 16rpx 74rpx; +border-image-repeat: stretch; +``` + +- [ ] **Step 4: 运行静态合同确认 GREEN** + +运行静态合同,预期输出 `A04-REGISTRATION-CONTRACT PASS`。 + +--- + +### Task 3: 真实运行验证与浏览器交付 + +**Files:** +- Modify: `design-qa.md` + +**Interfaces:** +- Consumes: A04 已迁移页面与 CDP 调试页。 +- Produces: 四档响应式结果和浏览器中的 A04 默认态。 + +- [ ] **Step 1: 运行 A04 smoke** + +运行 `node tests/a04-registration-runtime-smoke.js`,预期输出 `A04-REGISTRATION-RUNTIME-SMOKE PASS`。 + +- [ ] **Step 2: 捕获并检查 412×915** + +运行 `capture-chrome-page.js` 捕获 A04 默认态,人工检查 Logo、标题、字段、按钮、协议和底部留白。 + +- [ ] **Step 3: 更新 QA 边界** + +在 `design-qa.md` 的 A04 段记录新资产和真实滑动验证延期;不得写成用户已验收。 + +- [ ] **Step 4: 最终聚焦验证** + +重新运行静态合同、运行 smoke、截图合同和 `git diff --check`;将浏览器停在 A04 默认态供用户审核。 + +项目禁止 Git 操作,因此本计划不包含提交步骤。 diff --git a/docs/superpowers/plans/2026-07-16-a05-a01-visual-system-migration.md b/docs/superpowers/plans/2026-07-16-a05-a01-visual-system-migration.md new file mode 100644 index 0000000..7143439 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-a05-a01-visual-system-migration.md @@ -0,0 +1,167 @@ +# A05 向 A01 视觉体系收拢实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` inline. 本任务集中修改同一 A05 页面,不派发子代理;项目禁止 worktree、Git 暂存、提交和推送。 + +**Goal:** 让 A05 的 Logo、字体、标题分隔、按钮、Toast 和成功结果容器统一使用 A01 已确定的新视觉资产,同时保留四字段重设流程。 + +**Architecture:** `pages/auth/a05-reset-password.vue` 继续独立拥有表单和本地状态。页面直接消费 A01 正式资产;静态 PowerShell 合同负责禁止旧资产回退,Chrome CDP 烟测负责验证交互与多尺寸运行态。 + +**Tech Stack:** uni-app、Vue 3 ` @@ -200,8 +174,8 @@ const submitRegister = () => { z-index: 1; top: 64rpx; left: 50%; - width: 220rpx; - height: 264rpx; + width: 184rpx; + height: 221rpx; transform: translateX(-50%); pointer-events: none; } @@ -240,7 +214,6 @@ const submitRegister = () => { .page-title { color: #9f170f; - font-family: STSong, SimSun, serif; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; @@ -254,9 +227,9 @@ const submitRegister = () => { } .register-divider { - width: 154rpx; - height: 34rpx; - margin-top: 10rpx; + width: 300rpx; + height: 50rpx; + filter: brightness(0.68) saturate(1.5) contrast(1.2); } .form-content { margin-top: 12rpx; } @@ -273,7 +246,6 @@ const submitRegister = () => { .input-label { flex: 0 0 152rpx; color: #674b35; - font-family: STSong, SimSun, serif; font-size: 31rpx; } @@ -319,7 +291,6 @@ const submitRegister = () => { position: relative; z-index: 1; color: #fffaf0; - font-family: STSong, SimSun, serif; font-size: 39rpx; letter-spacing: 6rpx; } @@ -388,19 +359,19 @@ const submitRegister = () => { display: flex; align-items: center; justify-content: center; + box-sizing: border-box; width: 590rpx; min-height: 82rpx; + border: 16rpx solid transparent; + border-width: 16rpx 74rpx; + border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png'); + border-image-slice: 58 280 fill; + border-image-width: 16rpx 74rpx; + border-image-repeat: stretch; + background: transparent; transform: translateX(-50%); } -.feedback-toast__skin, -.verification-dialog__skin { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - .feedback-toast__copy { position: relative; z-index: 1; @@ -410,59 +381,4 @@ const submitRegister = () => { text-align: center; } -.verification-layer { - position: fixed; - z-index: 40; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 40rpx; - background: rgba(31, 20, 13, 0.56); -} - -.verification-dialog { - position: relative; - width: 620rpx; - height: 520rpx; -} - -.verification-dialog__content { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; - box-sizing: border-box; - height: 100%; - padding: 90rpx 64rpx 54rpx; -} - -.verification-title { - color: #6f140f; - font-family: STSong, SimSun, serif; - font-size: 42rpx; - font-weight: 700; -} - -.verification-copy { margin-top: 42rpx; color: #493323; font-size: 28rpx; } -.verification-note { margin-top: 20rpx; color: #8d7a68; font-size: 23rpx; } - -.verification-actions { - display: flex; - width: 100%; - margin-top: auto; -} - -.verification-action { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - min-height: 88rpx; - color: #745b46; - font-size: 28rpx; -} - -.verification-action--primary { color: #a9160d; font-weight: 700; } diff --git a/pages/auth/a05-reset-password.vue b/pages/auth/a05-reset-password.vue index d3c3553..2bc92de 100644 --- a/pages/auth/a05-reset-password.vue +++ b/pages/auth/a05-reset-password.vue @@ -12,7 +12,7 @@ 重设密码 验证手机号后设置新的登录密码 - + @@ -28,7 +28,7 @@ 验证码 - {{ codeRequested ? '重新获取' : '获取验证码' }} + 获取验证码 {{ fieldErrors.verificationCode }} @@ -51,7 +51,7 @@ - + 确认重设 @@ -63,31 +63,15 @@ - - - - - - 安全验证 - 发送验证码前需要完成行为验证 - 验证数据将在接口接入后启用 - - 取消 - 完成验证 - - - - - - + 密码已重设 请返回登录页,使用新密码登录 - + 返回登录 @@ -95,7 +79,6 @@ @@ -109,8 +92,6 @@ const phone = ref('') const verificationCode = ref('') const password = ref('') const confirmPassword = ref('') -const codeRequested = ref(false) -const verificationVisible = ref(false) const successVisible = ref(false) const feedbackVisible = ref(false) const feedbackMessage = ref('') @@ -151,17 +132,7 @@ const prepareGetCode = () => { return } fieldErrors.value.phone = '' - verificationVisible.value = true -} - -const closeVerification = () => { - verificationVisible.value = false -} - -const confirmVerification = () => { - closeVerification() - codeRequested.value = true - showFeedback('验证码已发送') + showFeedback('滑动验证待接口接入') } const validateForm = () => { @@ -214,8 +185,8 @@ const submitReset = () => { z-index: 1; top: 64rpx; left: 50%; - width: 220rpx; - height: 264rpx; + width: 184rpx; + height: 221rpx; transform: translateX(-50%); pointer-events: none; } @@ -247,9 +218,9 @@ const submitReset = () => { } .back-button__icon { width: 42rpx; height: 42rpx; transform: scaleX(-1); } -.page-title { color: #9f170f; font-family: STSong, SimSun, serif; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; } +.page-title { color: #9f170f; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; } .page-subtitle { margin-top: 12rpx; color: #806c58; font-size: 25rpx; letter-spacing: 1rpx; } -.reset-divider { width: 154rpx; height: 34rpx; margin-top: 10rpx; } +.reset-divider { width: 300rpx; height: 50rpx; margin-top: 2rpx; filter: brightness(0.68) saturate(1.5) contrast(1.2); } .form-content { margin-top: 8rpx; } .field-block { min-height: 114rpx; } @@ -260,7 +231,7 @@ const submitReset = () => { border-bottom: 1rpx solid #cfaa70; } -.input-label { flex: 0 0 162rpx; color: #674b35; font-family: STSong, SimSun, serif; font-size: 29rpx; } +.input-label { flex: 0 0 162rpx; color: #674b35; font-size: 29rpx; } .auth-input { flex: 1; min-width: 0; height: 82rpx; color: #3f2c1d; font-size: 27rpx; } .placeholder { color: #a79e94; } .get-code { flex: 0 0 auto; padding-left: 14rpx; border-left: 1rpx solid #d7bd94; color: #a7160c; font-size: 23rpx; } @@ -279,12 +250,11 @@ const submitReset = () => { .reset-submit__skin, .success-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; } -.reset-submit__content { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 35rpx; letter-spacing: 6rpx; } +.reset-submit__content { position: relative; z-index: 1; color: #fffaf0; font-size: 39rpx; letter-spacing: 6rpx; } .reset-tip { display: block; margin-top: 22rpx; color: #806c58; font-size: 22rpx; text-align: center; } .login-entry { display: flex; align-items: center; justify-content: center; min-height: 78rpx; color: #6b5a4a; font-size: 24rpx; } .login-entry__link { display: flex; align-items: center; min-height: 78rpx; margin-left: 8rpx; color: #a7160c; } -.verification-layer, .success-layer { position: fixed; z-index: 20; @@ -297,27 +267,17 @@ const submitReset = () => { background: rgba(35, 18, 10, 0.62); } -.verification-dialog, -.success-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 500rpx; } -.verification-dialog__skin, +.success-dialog { position: relative; width: 620rpx; height: 520rpx; } .success-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; } -.verification-dialog__content, -.success-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; min-height: 500rpx; padding: 92rpx 70rpx 64rpx; text-align: center; } -.verification-title, -.success-title { color: #8f160f; font-family: STSong, SimSun, serif; font-size: 45rpx; font-weight: 700; letter-spacing: 4rpx; } -.verification-copy, -.success-copy { margin-top: 32rpx; color: #513a28; font-size: 27rpx; line-height: 42rpx; } -.verification-note { margin-top: 14rpx; color: #8d7965; font-size: 22rpx; } -.verification-actions { display: flex; width: 100%; margin-top: 56rpx; border-top: 1rpx solid #d6bb91; } -.verification-action { display: flex; flex: 1; align-items: center; justify-content: center; min-height: 86rpx; color: #73533a; font-size: 27rpx; } -.verification-action--primary { border-left: 1rpx solid #d6bb91; color: #a7160c; } +.success-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; height: 100%; padding: 64rpx 64rpx 42rpx; text-align: center; } +.success-title { color: #8f160f; font-size: 43rpx; font-weight: 700; letter-spacing: 4rpx; } +.success-copy { margin-top: 18rpx; color: #513a28; font-size: 26rpx; line-height: 40rpx; } .success-mark { display: flex; align-items: center; justify-content: center; width: 82rpx; height: 82rpx; margin-bottom: 20rpx; border: 2rpx solid #a7160c; border-radius: 50%; color: #a7160c; font-size: 48rpx; } -.success-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 92rpx; margin-top: 38rpx; overflow: hidden; } -.success-action__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 30rpx; letter-spacing: 4rpx; } +.success-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 82rpx; margin-top: 24rpx; overflow: hidden; } +.success-action__copy { position: relative; z-index: 1; color: #fffaf0; font-size: 30rpx; letter-spacing: 4rpx; } -.feedback-toast { position: fixed; z-index: 30; top: calc(40rpx + env(safe-area-inset-top)); left: 50%; display: flex; align-items: center; justify-content: center; min-width: 280rpx; min-height: 80rpx; padding: 0 42rpx; transform: translateX(-50%); } -.feedback-toast__skin { position: absolute; inset: 0; width: 100%; height: 100%; } -.feedback-toast__copy { position: relative; z-index: 1; color: #5e402b; font-size: 24rpx; } +.feedback-toast { position: fixed; z-index: 30; top: calc(env(safe-area-inset-top) + 24rpx); left: 50%; display: flex; align-items: center; justify-content: center; box-sizing: border-box; width: 590rpx; min-height: 82rpx; border: 16rpx solid transparent; border-width: 16rpx 74rpx; border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png'); border-image-slice: 58 280 fill; border-image-width: 16rpx 74rpx; border-image-repeat: stretch; background: transparent; transform: translateX(-50%); } +.feedback-toast__copy { position: relative; z-index: 1; padding: 16rpx 36rpx; color: #5c4330; font-size: 25rpx; text-align: center; } .tap-fade, .button-hover { opacity: 0.72; } diff --git a/pages/auth/a06-auth-status.vue b/pages/auth/a06-auth-status.vue index 1a76dfe..97f0580 100644 --- a/pages/auth/a06-auth-status.vue +++ b/pages/auth/a06-auth-status.vue @@ -12,7 +12,7 @@ 登录受限 请根据当前账号状态完成恢复 - + @@ -38,7 +38,7 @@ - + 查看恢复方式 返回登录 @@ -47,13 +47,13 @@ - + {{ currentState.recoveryTitle }} {{ currentState.recoveryDetail }} 账号恢复服务将在功能阶段接入,当前仅审核页面样式与状态位置。 - + 我知道了 @@ -150,19 +150,19 @@ const goBack = () => uni.navigateBack() .page-canvas { position: relative; box-sizing: border-box; width: 100%; min-height: calc(1665rpx + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); } .page-backdrop { position: absolute; inset: 0 0 auto; width: 100%; height: 1665rpx; pointer-events: none; } -.brand-seal { position: absolute; z-index: 1; top: 64rpx; left: 50%; width: 220rpx; height: 264rpx; transform: translateX(-50%); pointer-events: none; } +.brand-seal { position: absolute; z-index: 1; top: 64rpx; left: 50%; width: 184rpx; height: 221rpx; transform: translateX(-50%); pointer-events: none; } .status-content { position: relative; z-index: 2; box-sizing: border-box; padding: 430rpx 64rpx 46rpx; } .page-heading { position: relative; display: flex; flex-direction: column; align-items: center; min-height: 154rpx; } .back-button { position: absolute; top: 10rpx; left: 0; display: flex; align-items: center; justify-content: center; width: 88rpx; height: 88rpx; } .back-button__icon { width: 42rpx; height: 42rpx; transform: scaleX(-1); } -.page-title { color: #9f170f; font-family: STSong, SimSun, serif; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; } +.page-title { color: #9f170f; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; } .page-subtitle { margin-top: 12rpx; color: #806c58; font-size: 25rpx; letter-spacing: 1rpx; } -.status-divider { width: 154rpx; height: 34rpx; margin-top: 10rpx; } +.status-divider { width: 300rpx; height: 50rpx; margin-top: 2rpx; filter: brightness(0.68) saturate(1.5) contrast(1.2); } .status-body { display: flex; flex-direction: column; align-items: center; padding-top: 6rpx; text-align: center; } .status-icon { width: 94rpx; height: 94rpx; opacity: 0.82; } .status-eyebrow { margin-top: 10rpx; color: #a7160c; font-size: 22rpx; letter-spacing: 3rpx; } -.status-title { margin-top: 10rpx; color: #3f2c1d; font-family: STSong, SimSun, serif; font-size: 40rpx; font-weight: 700; letter-spacing: 3rpx; } +.status-title { margin-top: 10rpx; color: #3f2c1d; font-size: 40rpx; font-weight: 700; letter-spacing: 3rpx; } .status-description { box-sizing: border-box; width: 100%; margin-top: 12rpx; color: #786654; font-size: 24rpx; line-height: 1.65; } .status-details { width: 100%; margin-top: 22rpx; border-top: 1rpx solid #cfaa70; } .status-detail { display: flex; box-sizing: border-box; min-height: 78rpx; padding: 16rpx 0; border-bottom: 1rpx solid rgba(207, 170, 112, 0.72); text-align: left; } @@ -172,18 +172,18 @@ const goBack = () => uni.navigateBack() .status-primary { position: relative; display: flex; align-items: center; justify-content: center; height: 104rpx; margin-top: 28rpx; overflow: hidden; } .status-primary__skin, .recovery-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; } -.status-primary__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 34rpx; letter-spacing: 5rpx; } +.status-primary__copy { position: relative; z-index: 1; color: #fffaf0; font-size: 38rpx; letter-spacing: 5rpx; } .status-secondary { display: flex; align-items: center; justify-content: center; min-height: 82rpx; color: #a7160c; font-size: 24rpx; } .recovery-layer { position: fixed; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 42rpx; background: rgba(35, 18, 10, 0.62); } -.recovery-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 560rpx; } +.recovery-dialog { position: relative; width: 620rpx; height: 520rpx; } .recovery-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; } -.recovery-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; min-height: 560rpx; padding: 92rpx 70rpx 64rpx; text-align: center; } -.recovery-title { color: #8f160f; font-family: STSong, SimSun, serif; font-size: 43rpx; font-weight: 700; letter-spacing: 4rpx; } -.recovery-copy { margin-top: 32rpx; color: #513a28; font-size: 27rpx; line-height: 43rpx; } -.recovery-note { margin-top: 18rpx; color: #8d7965; font-size: 21rpx; line-height: 34rpx; } -.recovery-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 92rpx; margin-top: 38rpx; overflow: hidden; } -.recovery-action__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 30rpx; letter-spacing: 4rpx; } +.recovery-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; height: 100%; padding: 58rpx 58rpx 36rpx; text-align: center; } +.recovery-title { color: #8f160f; font-size: 42rpx; font-weight: 700; letter-spacing: 4rpx; } +.recovery-copy { margin-top: 20rpx; color: #513a28; font-size: 26rpx; line-height: 40rpx; } +.recovery-note { margin-top: 12rpx; color: #8d7965; font-size: 20rpx; line-height: 31rpx; } +.recovery-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 80rpx; margin-top: 22rpx; overflow: hidden; } +.recovery-action__copy { position: relative; z-index: 1; color: #fffaf0; font-size: 29rpx; letter-spacing: 4rpx; } .tap-fade, .button-hover { opacity: 0.72; } diff --git a/pages/genealogy/g01-my-genealogies.vue b/pages/genealogy/g01-my-genealogies.vue index 7bded2e..ed6f591 100644 --- a/pages/genealogy/g01-my-genealogies.vue +++ b/pages/genealogy/g01-my-genealogies.vue @@ -630,6 +630,7 @@ const openShortcut = (key) => { box-sizing: border-box; padding: 22rpx 26rpx; } +.application-record + .application-record { margin-top: 12rpx; } .application-record__skin { position: absolute; inset: 0; width: 100%; height: 100%; } .application-record__main { position: relative; z-index: 1; min-width: 0; flex: 1; } .application-record__title-row { display: flex; align-items: center; justify-content: space-between; } diff --git a/scripts/capture-chrome-page.js b/scripts/capture-chrome-page.js index ce3d7e8..4d8e26a 100644 --- a/scripts/capture-chrome-page.js +++ b/scripts/capture-chrome-page.js @@ -6,7 +6,7 @@ const renderSettleMilliseconds = 900 const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222' if (!url || !selector || !output) { - throw new Error('Usage: node scripts/capture-chrome-page.js [width] [height] [password-tab|sms-tab|g06-results|g06-empty|g06-invite|fresh-navigation]') + throw new Error('Usage: node scripts/capture-chrome-page.js [width] [height] [password-tab|sms-tab|invalid-login|verification-dialog|g06-results|g06-empty|g06-invite|fresh-navigation]') } const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) @@ -55,8 +55,8 @@ const waitForLoadedImages = async (send) => { } const prepareA01LoginState = async (send, action) => { - if (action !== 'password-tab' && action !== 'sms-tab') return - const tabIndex = action === 'password-tab' ? 0 : 1 + if (action !== 'password-tab' && action !== 'sms-tab' && action !== 'invalid-login' && action !== 'verification-dialog') return + const tabIndex = action === 'sms-tab' ? 1 : 0 for (let attempt = 0; attempt < 30; attempt += 1) { const prepared = await send('Runtime.evaluate', { @@ -78,10 +78,50 @@ const prepareA01LoginState = async (send, action) => { expression: `document.querySelectorAll('.login-tab')[${tabIndex}]?.classList.contains('active') === true`, returnByValue: true }) - if (active.result?.value) return + if (active.result?.value) break if (attempt === 29) throw new Error(`A01 did not enter the requested ${action} state`) await sleep(100) } + + if (action === 'invalid-login') { + await send('Runtime.evaluate', { expression: "document.querySelector('.login-submit').click()" }) + for (let attempt = 0; attempt < 30; attempt += 1) { + const visible = await send('Runtime.evaluate', { + expression: "Boolean(document.querySelector('.feedback-toast'))", + returnByValue: true + }) + if (visible.result?.value) return + if (attempt === 29) throw new Error('A01 invalid-login Toast did not render') + await sleep(100) + } + } + + if (action === 'verification-dialog') { + const prepared = await send('Runtime.evaluate', { + expression: `(() => { + const inputs = document.querySelectorAll('.auth-input input') + if (inputs.length < 2) return false + inputs[0].value = '13800138000' + inputs[0].dispatchEvent(new Event('input', { bubbles: true })) + inputs[1].value = 'demo-password' + inputs[1].dispatchEvent(new Event('input', { bubbles: true })) + document.querySelector('.agreement-row').click() + document.querySelector('.login-submit').click() + return true + })()`, + returnByValue: true + }) + if (!prepared.result?.value) throw new Error('Could not prepare A01 verification dialog state') + for (let attempt = 0; attempt < 30; attempt += 1) { + const visible = await send('Runtime.evaluate', { + expression: "Boolean(document.querySelector('.verification-dialog'))", + returnByValue: true + }) + if (visible.result?.value) return + if (attempt === 29) throw new Error('A01 verification dialog did not render') + await sleep(100) + } + } } const waitForRequestedUrl = async (send, url) => { diff --git a/static/assets/foundation/transparent/a01-primary-button-v2.png b/static/assets/foundation/transparent/a01-primary-button-v2.png new file mode 100644 index 0000000..4abe87d Binary files /dev/null and b/static/assets/foundation/transparent/a01-primary-button-v2.png differ diff --git a/static/assets/foundation/transparent/a01-scroll-primary-v3.png b/static/assets/foundation/transparent/a01-scroll-primary-v3.png new file mode 100644 index 0000000..e8dd61c Binary files /dev/null and b/static/assets/foundation/transparent/a01-scroll-primary-v3.png differ diff --git a/static/assets/foundation/transparent/a01-scroll-secondary-v3.png b/static/assets/foundation/transparent/a01-scroll-secondary-v3.png new file mode 100644 index 0000000..08dbde3 Binary files /dev/null and b/static/assets/foundation/transparent/a01-scroll-secondary-v3.png differ diff --git a/static/assets/foundation/transparent/a01-scroll-toast-v3.png b/static/assets/foundation/transparent/a01-scroll-toast-v3.png new file mode 100644 index 0000000..3565ba6 Binary files /dev/null and b/static/assets/foundation/transparent/a01-scroll-toast-v3.png differ diff --git a/static/assets/foundation/transparent/a01-secondary-button-v2.png b/static/assets/foundation/transparent/a01-secondary-button-v2.png new file mode 100644 index 0000000..b19bad8 Binary files /dev/null and b/static/assets/foundation/transparent/a01-secondary-button-v2.png differ diff --git a/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png b/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png new file mode 100644 index 0000000..09ac258 Binary files /dev/null and b/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png differ diff --git a/tests/a01-a02-ui-contract.ps1 b/tests/a01-a02-ui-contract.ps1 index b8b69d0..fc5dba0 100644 --- a/tests/a01-a02-ui-contract.ps1 +++ b/tests/a01-a02-ui-contract.ps1 @@ -33,6 +33,7 @@ $root = Split-Path -Parent $PSScriptRoot $entryPath = Join-Path $root 'pages/auth/a01-entry.vue' $legacyLoginPath = Join-Path $root 'pages/auth/a02-login.vue' $pagesPath = Join-Path $root 'pages.json' +$globalStylesPath = Join-Path $root 'styles/global.scss' if (-not (Test-Path -LiteralPath $entryPath)) { throw 'Missing the single A01 login page.' @@ -48,19 +49,59 @@ if ($pages.pages[0].path -ne 'pages/auth/a01-entry') { if ($pages.pages.path -contains 'pages/auth/a02-login') { throw 'pages.json must not retain the legacy A02 route.' } -if ($pages.pages.Count -ne 53) { - throw "Expected 53 final routes after the A02 merge, found $($pages.pages.Count)." +if ($pages.pages.Count -ne 52) { + throw "Expected 52 active routes after the A02 merge and A06 archive, found $($pages.pages.Count)." } $entry = Get-Content -LiteralPath $entryPath -Raw -Encoding utf8 +$globalStyles = Get-Content -LiteralPath $globalStylesPath -Raw -Encoding utf8 + +# Typography has one owner, and the title decoration participates in normal flow. +Assert-Contains -Content $globalStyles -Expected 'font-family: "STKaiti", "KaiTi", serif' -Message 'Global styles must own the STKaiti/KaiTi font contract.' +Assert-NotContains -Content $entry -Unexpected 'font-family:' -Message 'A01 must inherit the global font contract instead of overriding it locally.' + +$headingRule = [regex]::Match($entry, '(?ms)^\.login-heading\s*\{(?.*?)^\}') +if (-not $headingRule.Success) { + throw 'A01 is missing the login heading style rule.' +} +Assert-Contains -Content $headingRule.Groups['Body'].Value -Expected 'flex-direction: column;' -Message 'A01 login heading must lay out its title and divider vertically in normal flow.' +Assert-NotContains -Content $headingRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 login heading must not use positioning for ordinary vertical layout.' + +$titleRule = [regex]::Match($entry, '(?ms)^\.login-title\s*\{(?.*?)^\}') +if (-not $titleRule.Success) { + throw 'A01 is missing the login title style rule.' +} +Assert-NotContains -Content $titleRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 login title must remain in normal flow.' + +$dividerRule = [regex]::Match($entry, '(?ms)^\.title-divider\s*\{(?.*?)^\}') +if (-not $dividerRule.Success) { + throw 'A01 is missing the title divider style rule.' +} +foreach ($positioning in @('position: absolute', 'left: 50%', 'translateX(')) { + Assert-NotContains -Content $dividerRule.Groups['Body'].Value -Unexpected $positioning -Message "A01 title divider must not use manual positioning: $positioning" +} +Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'width: 360rpx;' -Message 'A01 title divider must be wide enough to remain visible on paper.' +Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'height: 60rpx;' -Message 'A01 title divider must retain a legible knot and line weight.' +Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'filter: brightness(0.68) saturate(1.5) contrast(1.2);' -Message 'A01 title divider must keep sufficient contrast against the paper background.' + +$brandSealRule = [regex]::Match($entry, '(?ms)^\.brand-seal\s*\{(?.*?)^\}') +if (-not $brandSealRule.Success) { + throw 'A01 is missing the brand seal style rule.' +} +Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'width: 184rpx;' -Message 'A01 brand seal must use the approved smaller width.' +Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'height: 221rpx;' -Message 'A01 brand seal must preserve its aspect ratio at the approved smaller height.' +Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'top: 64rpx;' -Message 'A01 brand seal must keep its approved vertical anchor.' +Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'left: 50%;' -Message 'A01 brand seal must remain horizontally centered.' # Final decorative surfaces must come from real bitmap assets. foreach ($asset in @( 'a01-red-hall-ink-backdrop-v1.png', 'brand-seal.png', - 'a01-title-divider-v2.png', - 'a01-primary-button.png', - 'a01-secondary-button.png', + 'a01-vnext-divider-v1.png', + 'a01-scroll-primary-v3.png', + 'a01-scroll-secondary-v3.png', + 'a01-scroll-toast-v3.png', + 'a01-scroll-dialog-v3.png', 'auth-wechat.png', 'a01-icon-phone-v1.png', 'a01-icon-lock-v1.png', @@ -80,10 +121,24 @@ foreach ($obsoleteVisualAsset in @( 'a01-login-scroll-frame-v1.png', 'a01-vnext-scroll-v1.png', 'auth-ink-scenery.png' + 'a01-title-divider-v2.png', + 'a01-primary-button-v2.png', + 'a01-secondary-button-v2.png', + 'a02-login-panel.png' )) { Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset" } +$buttonSkinTags = [regex]::Matches($entry, ']+>') +if ($buttonSkinTags.Count -ne 2) { + throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)." +} +foreach ($buttonSkinTag in $buttonSkinTags) { + Assert-Contains -Content $buttonSkinTag.Value -Expected 'mode="aspectFit"' -Message 'A01 button skins must use uniform aspectFit rendering.' + Assert-NotContains -Content $buttonSkinTag.Value -Unexpected '/opaque/' -Message 'A01 visible button skins must use cleaned transparent v2 assets.' + Assert-NotContains -Content $buttonSkinTag.Value -Unexpected 'mode="scaleToFill"' -Message 'A01 button skins must not distort fixed artwork with scaleToFill.' +} + $requiredCopy = @( '55m75b2V5a626LCx', '5a+G56CB55m75b2V', @@ -137,4 +192,13 @@ Assert-NotContains -Content $entry -Unexpected 'a01-icon-eye-closed-v1.png' -Mes Assert-NotContains -Content $entry -Unexpected 'a01-icon-verification-v1.png' -Message 'A01 SMS field must use a message-code icon instead of a success-state checkmark.' Assert-NotContains -Content $entry -Unexpected 'a01-icon-sms-code-v1.svg' -Message 'A01 SMS field must use the custom generated icon instead of the temporary third-party SVG.' Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must scroll naturally on short screens instead of compressing the selected design with height breakpoints.' +Assert-NotContains -Content $entry -Unexpected 'feedback-toast__skin' -Message 'A01 feedback Toast must use the dedicated nine-slice border asset instead of an absolutely stretched image.' + +$feedbackToastRule = [regex]::Match($entry, '(?ms)^\.feedback-toast\s*\{(?.*?)^\}') +if (-not $feedbackToastRule.Success) { + throw 'A01 is missing the custom feedback Toast style rule.' +} +Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected "border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');" -Message 'A01 feedback Toast must use the approved v3 nine-slice asset.' +Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'border-image-slice:' -Message 'A01 feedback Toast must declare fixed decorative slices.' +Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'fill' -Message 'A01 feedback Toast nine-slice must fill the paper center.' Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS' diff --git a/tests/a01-asset-alpha-audit.ps1 b/tests/a01-asset-alpha-audit.ps1 index 9a7d72e..91cbf79 100644 --- a/tests/a01-asset-alpha-audit.ps1 +++ b/tests/a01-asset-alpha-audit.ps1 @@ -18,13 +18,67 @@ $buttons = @( 'static/assets/foundation/opaque/a01-primary-button.png', 'static/assets/foundation/opaque/a01-secondary-button.png' ) +$v2Buttons = @( + 'static/assets/foundation/transparent/a01-primary-button-v2.png', + 'static/assets/foundation/transparent/a01-secondary-button-v2.png' +) +$v3Skins = @( + 'static/assets/foundation/transparent/a01-scroll-primary-v3.png', + 'static/assets/foundation/transparent/a01-scroll-secondary-v3.png', + 'static/assets/foundation/transparent/a01-scroll-toast-v3.png', + 'static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png' +) -foreach ($asset in $backgrounds + $icons + $buttons) { +foreach ($asset in $backgrounds + $icons + $buttons + $v2Buttons + $v3Skins) { if (-not (Test-Path -LiteralPath (Join-Path $root $asset))) { throw "Missing A01 asset: $asset" } } +foreach ($asset in $v3Skins) { + $bitmap = [System.Drawing.Bitmap]::FromFile((Join-Path $root $asset)) + $width = [int]$bitmap.Width + $height = [int]$bitmap.Height + $corners = @( + $bitmap.GetPixel(0, 0).A, + $bitmap.GetPixel(($width - 1), 0).A, + $bitmap.GetPixel(0, ($height - 1)).A, + $bitmap.GetPixel(($width - 1), ($height - 1)).A + ) + $bitmap.Dispose() + + $expectedSize = switch ($asset) { + 'static/assets/foundation/transparent/a01-scroll-primary-v3.png' { @(1866, 276) } + 'static/assets/foundation/transparent/a01-scroll-secondary-v3.png' { @(1866, 300) } + 'static/assets/foundation/transparent/a01-scroll-toast-v3.png' { @(1770, 246) } + 'static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png' { @(1860, 1560) } + } + if ($width -ne $expectedSize[0] -or $height -ne $expectedSize[1]) { throw "$asset must be $($expectedSize[0]) x $($expectedSize[1])px." } + if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw "$asset must have transparent outer corners." } + Write-Output "PASS $asset transparent corners and exact v3 slot ratio" +} + +foreach ($asset in $v2Buttons) { + $bitmap = [System.Drawing.Bitmap]::FromFile((Join-Path $root $asset)) + $width = [int]$bitmap.Width + $height = [int]$bitmap.Height + $corners = @( + $bitmap.GetPixel(0, 0).A, + $bitmap.GetPixel(($width - 1), 0).A, + $bitmap.GetPixel(0, ($height - 1)).A, + $bitmap.GetPixel(($width - 1), ($height - 1)).A + ) + $bitmap.Dispose() + + $expectedSize = switch ($asset) { + 'static/assets/foundation/transparent/a01-primary-button-v2.png' { @(1866, 276) } + 'static/assets/foundation/transparent/a01-secondary-button-v2.png' { @(1866, 300) } + } + if ($width -ne $expectedSize[0] -or $height -ne $expectedSize[1]) { throw "$asset must be $($expectedSize[0]) x $($expectedSize[1])px." } + if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw "$asset must have transparent outer corners." } + Write-Output "PASS $asset transparent corners and exact runtime ratio" +} + foreach ($asset in $icons) { $bitmap = [System.Drawing.Bitmap]::FromFile((Join-Path $root $asset)) $width = [int]$bitmap.Width diff --git a/tests/a01-responsive-runtime-smoke.js b/tests/a01-responsive-runtime-smoke.js index 8057855..5b92dcf 100644 --- a/tests/a01-responsive-runtime-smoke.js +++ b/tests/a01-responsive-runtime-smoke.js @@ -56,6 +56,14 @@ const waitFor = async (send, expression, message) => { throw new Error(message) } +const waitForFreshDocument = async (send, previousTimeOrigin) => { + await waitFor( + send, + `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, + 'A01 navigation did not create a fresh document' + ) +} + const run = async () => { const { socket, send, exceptions } = await connect() try { @@ -70,7 +78,10 @@ const run = async () => { mobile: true }) await send('Page.navigate', { url }) + await waitFor(send, `location.href === ${JSON.stringify(url)}`, `A01 did not navigate at ${size.width}x${size.height}`) + const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin') await send('Page.reload') + await waitForFreshDocument(send, previousTimeOrigin) await waitFor(send, "document.querySelectorAll('.login-tab').length === 2", `A01 did not render at ${size.width}x${size.height}`) if (size.width === 320 && size.height === 568) { await waitFor(send, "document.querySelector('.auth-page')?.scrollHeight > document.querySelector('.auth-page')?.clientHeight", 'A01 did not establish natural scrolling at 320x568') @@ -86,11 +97,30 @@ const run = async () => { })`) assert.strictEqual(metrics.innerWidth, size.width, `A01 viewport width mismatch at ${size.width}x${size.height}`) assert(metrics.scrollWidth <= size.width + 1, `A01 has horizontal overflow at ${size.width}x${size.height}: ${metrics.scrollWidth}`) + + const buttonAssets = await valueOf(send, `Array.from(document.querySelectorAll('.button-skin img')).map((image) => ({ + src: image.currentSrc || image.src, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight + }))`) + assert.strictEqual(buttonAssets.length, 2, `A01 button skins did not render at ${size.width}x${size.height}`) + assert(buttonAssets[0].src.includes('a01-scroll-primary-v3.png'), `A01 primary v3 skin is missing at ${size.width}x${size.height}`) + assert(buttonAssets[1].src.includes('a01-scroll-secondary-v3.png'), `A01 secondary v3 skin is missing at ${size.width}x${size.height}`) + assert.deepStrictEqual( + buttonAssets.map(({ naturalWidth, naturalHeight }) => [naturalWidth, naturalHeight]), + [[1866, 276], [1866, 300]], + `A01 v3 button assets have unexpected runtime dimensions at ${size.width}x${size.height}` + ) if (size.width === 320 && size.height === 568) { assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A01 must scroll naturally at 320x568') } } + await valueOf(send, "document.querySelector('.login-submit').click()") + await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A01 invalid phone Toast did not render') + const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.feedback-toast')).borderImageSource") + assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A01 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`) + await valueOf(send, "document.querySelectorAll('.login-tab')[1].click()") await waitFor(send, "document.querySelectorAll('.login-tab')[1].classList.contains('active')", 'A01 SMS tab did not activate') await waitFor(send, "Boolean(document.querySelector('.input-icon--sms img'))", 'A01 SMS field image did not render after tab activation') @@ -117,6 +147,17 @@ const run = async () => { await waitFor(send, "!document.querySelector('.agreement-error')", 'A01 agreement error did not clear after selection') await valueOf(send, "document.querySelector('.login-submit').click()") await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A01 did not open the custom verification layer after local validation') + await waitFor(send, "document.querySelector('.verification-dialog__skin img')?.naturalWidth === 1860", 'A01 verification dialog v3 asset did not finish loading') + const dialogAsset = await valueOf(send, `(() => { + const image = document.querySelector('.verification-dialog__skin img') + return image ? { src: image.currentSrc || image.src, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight } : null + })()`) + assert(dialogAsset?.src.includes('a01-scroll-dialog-v3.png'), `A01 verification dialog did not load the v3 asset: ${dialogAsset?.src}`) + assert.deepStrictEqual( + [dialogAsset.naturalWidth, dialogAsset.naturalHeight], + [1860, 1560], + 'A01 verification dialog loaded unexpected natural dimensions' + ) assert.deepStrictEqual(exceptions, [], `A01 raised browser exceptions: ${exceptions.join('; ')}`) process.stdout.write('A01-RESPONSIVE-RUNTIME-SMOKE PASS\n') diff --git a/tests/a02-route-removal-contract.ps1 b/tests/a02-route-removal-contract.ps1 index 0091df3..78863a9 100644 --- a/tests/a02-route-removal-contract.ps1 +++ b/tests/a02-route-removal-contract.ps1 @@ -4,7 +4,7 @@ $root = Split-Path -Parent $PSScriptRoot $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json $legacyPage = Join-Path $root 'pages/auth/a02-login.vue' -if ($pages.pages.Count -ne 53) { throw "Expected 53 routes, found $($pages.pages.Count)." } +if ($pages.pages.Count -ne 52) { throw "Expected 52 active routes, found $($pages.pages.Count)." } if ($pages.pages.path -contains 'pages/auth/a02-login') { throw 'Legacy A02 route still exists.' } if (Test-Path -LiteralPath $legacyPage) { throw 'Legacy A02 page file still exists.' } diff --git a/tests/a03-route-removal-contract.ps1 b/tests/a03-route-removal-contract.ps1 index f69bca7..4be53eb 100644 --- a/tests/a03-route-removal-contract.ps1 +++ b/tests/a03-route-removal-contract.ps1 @@ -8,6 +8,6 @@ $a03File = Join-Path $root 'pages/auth/a03-mobile-verify.vue' if ($pages.pages.path -contains 'pages/auth/a03-mobile-verify') { throw 'A-03 must not remain in pages.json' } if (Test-Path -LiteralPath $a03File) { throw 'A-03 page file must be deleted' } if ($catalog -match '(?m)^\s*a03\s*:') { throw 'A-03 catalog entry must be deleted' } -if ($pages.pages.Count -ne 53) { throw "Expected 53 final routes after the approved page-state merges, found $($pages.pages.Count)." } +if ($pages.pages.Count -ne 52) { throw "Expected 52 active routes after the approved page-state merges and A06 archive, found $($pages.pages.Count)." } Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS' diff --git a/tests/a04-registration-contract.ps1 b/tests/a04-registration-contract.ps1 index 05c6c91..8aeb5c6 100644 --- a/tests/a04-registration-contract.ps1 +++ b/tests/a04-registration-contract.ps1 @@ -20,16 +20,19 @@ $registerPath = Join-Path $root 'pages/auth/a04-register.vue' $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json $catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8 $register = Get-Content -LiteralPath $registerPath -Raw -Encoding utf8 +$globalStyles = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8 $registerCopy = ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3' $loginCopy = ConvertFrom-Utf8Base64 '55m75b2V' +$sliderPendingCopy = ConvertFrom-Utf8Base64 '5ruR5Yqo6aqM6K+B5b6F5o6l5Y+j5o6l5YWl' if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-04 route must remain declared in pages.json' } Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell' foreach ($required in @( 'a01-red-hall-ink-backdrop-v1.png', 'brand-seal.png', - 'a02-login-panel.png', - 'a01-primary-button.png', + 'a01-vnext-divider-v1.png', + 'a01-scroll-primary-v3.png', + 'a01-scroll-toast-v3.png', 'a02-agreement-unchecked.png', 'a02-agreement-checked.png', 'v-model.trim="phone"', @@ -38,11 +41,10 @@ foreach ($required in @( 'const agreed = ref(false)', 'const agreementError = ref(false)', 'const fieldErrors = ref({', - 'const verificationVisible = ref(false)', 'const toggleAgreement = () =>', 'const submitRegister = () =>', 'class="feedback-toast"', - 'class="verification-layer"', + $sliderPendingCopy, "const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })", 'if (!/^1\d{10}$/.test(phone.value))', 'if (!password.value)', @@ -56,13 +58,28 @@ Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must prov Assert-NotContains -Content $catalog -Unexpected 'a04:' -Message 'A-04 must not remain in the temporary page catalog' Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy' Assert-NotContains -Content $register -Unexpected 'auth-design-scenery-v1.png' -Message 'A-04 must use the shared red-hall ink backdrop instead of the rejected scenery layer' +Assert-Contains -Content $globalStyles -Expected 'font-family: "STKaiti", "KaiTi", serif' -Message 'Global styles must own the A04 font contract.' +Assert-NotContains -Content $register -Unexpected 'font-family:' -Message 'A04 must inherit the global font contract instead of overriding it locally.' +foreach ($obsoleteVisual in @( + 'a01-primary-button.png', + 'a01-secondary-button.png', + 'a02-login-panel.png', + 'verificationVisible', + 'verification-layer', + 'feedback-toast__skin' +)) { + Assert-NotContains -Content $register -Unexpected $obsoleteVisual -Message "A04 must not retain obsolete visual or placeholder state: $obsoleteVisual" +} foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) { Assert-NotContains -Content $register -Unexpected $nativeUi -Message "A-04 must not use native UniApp feedback: $nativeUi" } foreach ($required in @( - 'auth-divider-knot.png', 'register-divider', - 'width: 154rpx' + 'mode="aspectFit"', + 'width: 184rpx;', + 'height: 221rpx;', + "border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');", + 'border-image-slice: 58 280 fill;' )) { Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required" } diff --git a/tests/a04-registration-runtime-smoke.js b/tests/a04-registration-runtime-smoke.js index 0dacf20..8ad9ab9 100644 --- a/tests/a04-registration-runtime-smoke.js +++ b/tests/a04-registration-runtime-smoke.js @@ -68,7 +68,7 @@ const run = async () => { await valueOf(send, "document.querySelector('.register-link').click()") await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'A01 registration entry did not open A04') - for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { + for (const size of [{ width: 320, height: 568 }, { width: 360, height: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true }) await navigate(send, a04Url, '.register-submit') const metrics = await valueOf(send, `({ @@ -94,9 +94,11 @@ const run = async () => { document.querySelector('.agreement-row').click() document.querySelector('.register-submit').click() })()`) - await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A04 valid submit did not open custom verification') - await valueOf(send, "document.querySelector('.verification-action--primary').click()") - await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A04 verification result did not use custom feedback') + await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A04 valid submit did not show slider integration feedback') + assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast__copy')?.textContent"), '滑动验证待接口接入', 'A04 valid submit showed unexpected feedback') + assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A04 must not retain the non-final custom verification placeholder') + const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.feedback-toast')).borderImageSource") + assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A04 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`) await navigate(send, a04Url, '.login-entry__link') await valueOf(send, "document.querySelector('.login-entry__link').click()") diff --git a/tests/a05-reset-password-contract.ps1 b/tests/a05-reset-password-contract.ps1 index 695fc36..03927b1 100644 --- a/tests/a05-reset-password-contract.ps1 +++ b/tests/a05-reset-password-contract.ps1 @@ -24,15 +24,17 @@ $resetCopy = ConvertFrom-Utf8Base64 '6YeN6K6+5a+G56CB' $confirmPasswordCopy = ConvertFrom-Utf8Base64 '56Gu6K6k5paw5a+G56CB' $getCodeCopy = ConvertFrom-Utf8Base64 '6I635Y+W6aqM6K+B56CB' $loginCopy = ConvertFrom-Utf8Base64 '6L+U5Zue55m75b2V' +$sliderPendingCopy = ConvertFrom-Utf8Base64 '5ruR5Yqo6aqM6K+B5b6F5o6l5Y+j5o6l5YWl' if (-not ($pages.pages.path -contains 'pages/auth/a05-reset-password')) { throw 'A-05 route must remain declared in pages.json' } Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell' foreach ($required in @( 'a01-red-hall-ink-backdrop-v1.png', 'brand-seal.png', - 'a02-login-panel.png', - 'a01-primary-button.png', - 'auth-divider-knot.png', + 'a01-vnext-divider-v1.png', + 'a01-scroll-primary-v3.png', + 'a01-scroll-toast-v3.png', + 'a01-scroll-dialog-v3.png', 'chevron-right.png', 'v-model.trim="phone"', 'v-model.trim="verificationCode"', @@ -41,16 +43,18 @@ foreach ($required in @( 'const prepareGetCode = () =>', 'const submitReset = () =>', 'const fieldErrors = ref({', - 'const verificationVisible = ref(false)', 'const successVisible = ref(false)', - 'class="verification-layer"', 'class="feedback-toast"', 'class="success-layer"', "const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })", 'if (!/^1\d{10}$/.test(phone.value))', 'if (!/^\d{6}$/.test(verificationCode.value))', 'if (!password.value)', - 'if (password.value !== confirmPassword.value)' + 'if (password.value !== confirmPassword.value)', + 'width: 184rpx;', + 'height: 221rpx;', + "border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');", + 'border-image-slice: 58 280 fill;' )) { Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required" } @@ -58,8 +62,27 @@ Assert-Contains -Content $page -Expected $resetCopy -Message 'A-05 must visibly Assert-Contains -Content $page -Expected $confirmPasswordCopy -Message 'A-05 must require password confirmation' Assert-Contains -Content $page -Expected $getCodeCopy -Message 'A-05 must expose the verification-code action' Assert-Contains -Content $page -Expected $loginCopy -Message 'A-05 must provide the A01 return path' +Assert-Contains -Content $page -Expected $sliderPendingCopy -Message 'A-05 must defer the real slider to the interface stage' Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog' Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-05 must use the selected red-hall ink backdrop' +foreach ($obsoleteVisual in @( + 'a01-primary-button.png', + 'a01-secondary-button.png', + 'a02-login-panel.png', + 'auth-divider-knot.png', + 'feedback-toast__skin', + 'codeRequested', + 'verificationVisible', + 'verification-layer', + 'closeVerification', + 'confirmVerification', + 'font-family:' +)) { + Assert-NotContains -Content $page -Unexpected $obsoleteVisual -Message "A-05 must not retain obsolete visual or placeholder state: $obsoleteVisual" +} + +$primarySkinCount = ([regex]::Matches($page, 'a01-scroll-primary-v3\.png')).Count +if ($primarySkinCount -ne 2) { throw "A-05 must use the approved primary skin exactly twice, found $primarySkinCount" } foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) { Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-05 must not use native UniApp feedback: $nativeUi" } diff --git a/tests/a05-reset-password-runtime-smoke.js b/tests/a05-reset-password-runtime-smoke.js index 0dc58c5..70f9809 100644 --- a/tests/a05-reset-password-runtime-smoke.js +++ b/tests/a05-reset-password-runtime-smoke.js @@ -69,11 +69,13 @@ const run = async () => { await send('Page.enable') await send('Runtime.enable') - await navigate(send, a01Url, '.forgot-password') + await navigate(send, a01Url, '.login-tab') + await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()") + await waitFor(send, "Boolean(document.querySelector('.forgot-password'))", 'A01 password state did not expose forgot-password entry') await valueOf(send, "document.querySelector('.forgot-password').click()") await waitFor(send, "location.href.includes('/pages/auth/a05-reset-password')", 'A01 forgot-password entry did not open A05') - for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { + for (const size of [{ width: 320, height: 568 }, { width: 360, height: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true }) await navigate(send, a05Url, '.reset-submit') const metrics = await valueOf(send, `({ @@ -89,16 +91,11 @@ const run = async () => { await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error') await valueOf(send, setInputs(['13800138000', '', '', ''])) await valueOf(send, "document.querySelector('.get-code').click()") - await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 code request did not open custom verification') - await valueOf(send, "document.querySelector('.verification-action').click()") - await waitFor(send, "!document.querySelector('.verification-layer')", 'A05 verification did not close') - assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 verification close cleared the form') - - await valueOf(send, "document.querySelector('.get-code').click()") - await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 verification did not reopen') - await valueOf(send, "document.querySelector('.verification-action--primary').click()") - await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 verified code request did not use custom feedback') - assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent"), '重新获取', 'A05 code action did not enter requested state') + await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 code request did not use custom pending feedback') + assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast').textContent.trim()"), '滑动验证待接口接入', 'A05 code request showed the wrong pending feedback') + assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A05 must not retain the old verification panel') + assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 pending slider feedback cleared the form') + assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent.trim()"), '获取验证码', 'A05 must not fake a requested-code state before the real slider and SMS interface') await valueOf(send, "document.querySelector('.reset-submit').click()") await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors') diff --git a/tests/a06-auth-status-contract.ps1 b/tests/a06-auth-status-contract.ps1 index 730fd2a..f8b0221 100644 --- a/tests/a06-auth-status-contract.ps1 +++ b/tests/a06-auth-status-contract.ps1 @@ -14,9 +14,12 @@ $root = Split-Path -Parent $PSScriptRoot $page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue') -Raw -Encoding utf8 $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json $catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8 +$runtime = Get-Content -LiteralPath (Join-Path $root 'tests/a06-auth-status-runtime-smoke.js') -Raw -Encoding utf8 -if (-not ($pages.pages.path -contains 'pages/auth/a06-auth-status')) { throw 'A-06 route must remain declared in pages.json' } +if ($pages.pages.path -contains 'pages/auth/a06-auth-status') { throw 'A-06 is archived and must not remain declared in pages.json' } +if (-not (Test-Path -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue'))) { throw 'Archived A-06 source must be retained' } if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 must retain the A01 return target' } +Assert-Contains -Content $runtime -Expected 'A06-AUTH-STATUS-RUNTIME-SMOKE SKIP archived route' -Message 'A-06 runtime smoke must explicitly skip while the route is archived' Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell' Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog' @@ -37,12 +40,15 @@ foreach ($required in @( 'const goBack = () =>', 'a01-red-hall-ink-backdrop-v1.png', 'brand-seal.png', - 'a02-login-panel.png', - 'a01-primary-button.png', - 'auth-divider-knot.png', + 'a01-vnext-divider-v1.png', + 'a01-scroll-primary-v3.png', + 'a01-scroll-dialog-v3.png', 'chevron-right.png', 'auth-login-outline.png', - 'class="recovery-layer"' + 'class="recovery-layer"', + 'width: 184rpx;', + 'height: 221rpx;', + 'mode="aspectFit"' )) { Assert-Contains -Content $page -Expected $required -Message "Missing A-06 auth-status contract: $required" } @@ -54,6 +60,17 @@ foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'un Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-06 must not use native UniApp feedback: $nativeUi" } Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-06 must use the selected red-hall ink backdrop' +foreach ($obsoleteVisual in @( + 'a01-primary-button.png', + 'a02-login-panel.png', + 'auth-divider-knot.png', + 'font-family:' +)) { + Assert-NotContains -Content $page -Unexpected $obsoleteVisual -Message "A-06 must not retain obsolete visual: $obsoleteVisual" +} + +$primarySkinCount = ([regex]::Matches($page, 'a01-scroll-primary-v3\.png')).Count +if ($primarySkinCount -ne 2) { throw "A-06 must use the approved primary skin exactly twice, found $primarySkinCount" } foreach ($forbidden in @( '(?s)\.status-panel\s*\{[^}]*\bborder\s*:', diff --git a/tests/a06-auth-status-runtime-smoke.js b/tests/a06-auth-status-runtime-smoke.js index 45ec18d..643b223 100644 --- a/tests/a06-auth-status-runtime-smoke.js +++ b/tests/a06-auth-status-runtime-smoke.js @@ -1,4 +1,13 @@ const assert = require('assert') +const fs = require('fs') +const path = require('path') + +const routeConfig = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'pages.json'), 'utf8')) +const isActiveRoute = routeConfig.pages.some((item) => item.path === 'pages/auth/a06-auth-status') +if (!isActiveRoute) { + process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE SKIP archived route\n') + process.exit(0) +} const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status' const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) @@ -60,7 +69,7 @@ const run = async () => { await send('Page.enable') await send('Runtime.enable') - for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { + for (const size of [{ width: 320, height: 568 }, { width: 360, height: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) { await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true }) await navigate(send, 'risk', '账号存在安全风险') const metrics = await valueOf(send, `({ diff --git a/tests/full-page-visual-contract.ps1 b/tests/full-page-visual-contract.ps1 index 3df2479..e450aa3 100644 --- a/tests/full-page-visual-contract.ps1 +++ b/tests/full-page-visual-contract.ps1 @@ -9,7 +9,7 @@ $root = Split-Path -Parent $PSScriptRoot $pagesPath = Join-Path $root 'pages.json' $pageHeader = ConvertFrom-Utf8Base64 '6aG16Z2i57yW5Y+377ya' $expectedPages = @( - 'pages/auth/a01-entry', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/auth/a06-auth-status', + 'pages/auth/a01-entry', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/genealogy/g01-my-genealogies', 'pages/genealogy/g03-create-genealogy', 'pages/genealogy/g05-genealogy-overview', 'pages/genealogy/g06-search-genealogies', 'pages/genealogy/g08-join-application', 'pages/genealogy/g09-my-applications', 'pages/genealogy/g10-application-review', 'pages/genealogy/g11-genealogy-settings', 'pages/genealogy/g12-generation-poems', 'pages/tree/t01-tree-overview', 'pages/tree/t03-member-profile', 'pages/tree/t04-add-relative', 'pages/tree/t05-edit-member', 'pages/tree/t06-edit-relationship', 'pages/tree/t07-member-directory', 'pages/tree/t08-member-states', 'pages/family/f01-family-feed', 'pages/family/f02-publish-feed', 'pages/family/f03-feed-detail', 'pages/family/f04-article-list', 'pages/family/f05-article-detail', 'pages/family/f06-article-editor', 'pages/family/f07-album-list', 'pages/family/f08-album-detail', 'pages/family/f09-media-upload', 'pages/family/f10-video-list', @@ -20,11 +20,11 @@ $expectedPages = @( $pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json $actualPaths = @($pages.pages.path) -if ($actualPaths.Count -ne 53) { - throw "Expected 53 visual routes after the approved page-state merges, found $($actualPaths.Count)." +if ($actualPaths.Count -ne 52) { + throw "Expected 52 active visual routes after the approved page-state merges and A06 archive, found $($actualPaths.Count)." } if (Compare-Object -ReferenceObject $expectedPages -DifferenceObject $actualPaths) { - throw 'pages.json routes do not match the 53-route visual delivery contract.' + throw 'pages.json routes do not match the 52-route active visual delivery contract.' } foreach ($page in $expectedPages) { diff --git a/tests/g01-visual-contract.ps1 b/tests/g01-visual-contract.ps1 index 250222b..a0936a3 100644 --- a/tests/g01-visual-contract.ps1 +++ b/tests/g01-visual-contract.ps1 @@ -117,6 +117,7 @@ if ($card -notmatch '(?s)\.card-title-row\s*\{.*?justify-content:\s*space-betwee if ($card -notmatch '(?s)\.card-meta\s*\{.*?font-size:\s*26rpx') { throw 'G-01 list metadata is below the approved readable size.' } if ($card -notmatch '(?s)\.card-updated\s*\{.*?font-size:\s*22rpx') { throw 'G-01 list update date is below the approved readable size.' } if ($page -notmatch '(?s)\.create-action\s*>\s*text\s*\{.*?white-space:\s*nowrap') { throw 'G-01 create action text can wrap onto multiple lines.' } +if ($page -notmatch '(?s)\.application-record\s*\+\s*\.application-record\s*\{[^}]*margin-top:\s*12rpx') { throw 'G-01 adjacent application cards must keep the approved 12rpx separation.' } $manifest = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'manifest.json') if ($manifest -notmatch '"statusbar"\s*:\s*\{\s*"immersed"\s*:\s*"supportedDevice"\s*,\s*"style"\s*:\s*"light"\s*,\s*"background"\s*:\s*"#B52E22"') { throw 'Android status bar is not configured for the cinnabar root header.' }