xhs-minitool-creator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/bin/create.mjs +321 -0
  4. package/package.json +39 -0
  5. package/src/index.mjs +9 -0
  6. package/src/pack.mjs +188 -0
  7. package/src/skill.mjs +188 -0
  8. package/src/validate.mjs +347 -0
  9. package/src/vite-preset.mjs +46 -0
  10. package/src/zip.mjs +190 -0
  11. package/template/.agents/skills/minitool-zip-builder/SKILL.md +47 -0
  12. package/template/.agents/skills/minitool-zip-builder/references/cross-platform-h5.md +69 -0
  13. package/template/.agents/skills/minitool-zip-builder/references/css-compatibility.md +171 -0
  14. package/template/.agents/skills/minitool-zip-builder/references/device-capabilities.md +169 -0
  15. package/template/.agents/skills/minitool-zip-builder/references/js-compatibility.md +61 -0
  16. package/template/.agents/skills/minitool-zip-builder/references/jsbridge-api.md +192 -0
  17. package/template/.agents/skills/minitool-zip-builder/references/performance-budget.md +131 -0
  18. package/template/.agents/skills/minitool-zip-builder/references/zip-artifact-spec.md +206 -0
  19. package/template/.agents/skills/minitool-zip-builder/scripts/audit_artifact.mjs +95 -0
  20. package/template/.agents/skills/minitool-zip-builder/scripts/audit_artifact.py +109 -0
  21. package/template/.agents/skills/minitool-zip-builder/skill-package.json +5 -0
  22. package/template/_gitignore +7 -0
  23. package/template/index.html +47 -0
  24. package/template/package.json +22 -0
  25. package/template/public/icons/icon-192.svg +4 -0
  26. package/template/public/icons/icon-512.svg +4 -0
  27. package/template/src/lib/storage.js +54 -0
  28. package/template/src/main.js +81 -0
  29. package/template/src/styles/app.css +229 -0
  30. package/template/vite.config.js +9 -0
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /** Audit deterministic mini-tool package and text-file size limits. */
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const MIB = 1024 * 1024;
8
+ const ZIP_LIMIT = 10 * MIB;
9
+ const ZIP_RECOMMENDED = 2 * MIB;
10
+ const TEXT_FILE_WARNING = 2 * MIB;
11
+ const TEXT_TOTAL_WARNING = 5 * MIB;
12
+ const TEXT_SUFFIXES = new Set([".html", ".css", ".js", ".json"]);
13
+
14
+ function formatSize(size) {
15
+ return `${(size / MIB).toFixed(2)} MiB`;
16
+ }
17
+
18
+ function walkDirectory(root) {
19
+ const files = [];
20
+ function walk(current) {
21
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
22
+ if (entry.name === ".git") continue;
23
+ const target = path.join(current, entry.name);
24
+ if (entry.isDirectory()) walk(target);
25
+ else if (entry.isFile()) files.push(target);
26
+ }
27
+ }
28
+ walk(root);
29
+ return files;
30
+ }
31
+
32
+ function auditDirectory(root) {
33
+ const errors = [];
34
+ const warnings = [];
35
+ const files = walkDirectory(root);
36
+ let textTotal = 0;
37
+
38
+ for (const file of files) {
39
+ if (!TEXT_SUFFIXES.has(path.extname(file).toLowerCase())) continue;
40
+ const size = fs.statSync(file).size;
41
+ const name = path.relative(root, file).split(path.sep).join("/");
42
+ textTotal += size;
43
+ if (size > TEXT_FILE_WARNING) {
44
+ warnings.push(`${name}: text file is ${formatSize(size)}; review parse and memory cost`);
45
+ }
46
+ }
47
+ if (textTotal > TEXT_TOTAL_WARNING) {
48
+ warnings.push(`uncompressed HTML/CSS/JS/JSON total is ${formatSize(textTotal)}; review for embedded databases or generated content`);
49
+ }
50
+ return { errors, warnings, count: files.length };
51
+ }
52
+
53
+ function auditZipSize(file) {
54
+ const errors = [];
55
+ const warnings = [];
56
+ const size = fs.statSync(file).size;
57
+ if (size > ZIP_LIMIT) errors.push(`${path.basename(file)}: zip is ${formatSize(size)}; hard limit is 10 MiB`);
58
+ else if (size > ZIP_RECOMMENDED) warnings.push(`${path.basename(file)}: zip is ${formatSize(size)}; recommended target is 2 MiB`);
59
+ return { errors, warnings, count: 1, note: "Node audit checks zip size only; artifact contents must be audited before packaging" };
60
+ }
61
+
62
+ function main() {
63
+ const target = process.argv[2];
64
+ if (!target) {
65
+ console.error("Usage: node audit_artifact.mjs <artifact-directory-or-zip>");
66
+ return 2;
67
+ }
68
+ const resolved = path.resolve(target);
69
+ if (!fs.existsSync(resolved)) {
70
+ console.error(`ERROR: path not found: ${resolved}`);
71
+ return 2;
72
+ }
73
+
74
+ let result;
75
+ try {
76
+ result = fs.statSync(resolved).isDirectory()
77
+ ? auditDirectory(resolved)
78
+ : auditZipSize(resolved);
79
+ } catch (error) {
80
+ console.error(`ERROR: cannot inspect ${resolved}: ${error.message}`);
81
+ return 2;
82
+ }
83
+
84
+ for (const message of result.errors) console.log(`ERROR: ${message}`);
85
+ for (const message of result.warnings) console.log(`WARN: ${message}`);
86
+ if (result.note) console.log(`NOTE: ${result.note}`);
87
+ if (result.errors.length) {
88
+ console.log(`FAILED: ${result.errors.length} error(s), ${result.warnings.length} warning(s)`);
89
+ return 1;
90
+ }
91
+ console.log(`PASS: ${result.count} file(s), ${result.warnings.length} warning(s)`);
92
+ return 0;
93
+ }
94
+
95
+ process.exitCode = main();
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """Audit deterministic mini-tool package and text-file size limits."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import zipfile
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ MIB = 1024 * 1024
11
+ ZIP_LIMIT = 10 * MIB
12
+ ZIP_RECOMMENDED = 2 * MIB
13
+ TEXT_FILE_WARNING = 2 * MIB
14
+ TEXT_TOTAL_WARNING = 5 * MIB
15
+ TEXT_SUFFIXES = {".html", ".css", ".js", ".json"}
16
+
17
+
18
+ def format_size(size: int) -> str:
19
+ return f"{size / MIB:.2f} MiB"
20
+
21
+
22
+ def audit_entries(entries: list[tuple[str, int]]) -> list[str]:
23
+ warnings: list[str] = []
24
+ text_total = 0
25
+ for name, size in entries:
26
+ if PurePosixPath(name).suffix.lower() not in TEXT_SUFFIXES:
27
+ continue
28
+ text_total += size
29
+ if size > TEXT_FILE_WARNING:
30
+ warnings.append(
31
+ f"{name}: text file is {format_size(size)}; review parse and memory cost"
32
+ )
33
+
34
+ if text_total > TEXT_TOTAL_WARNING:
35
+ warnings.append(
36
+ f"uncompressed HTML/CSS/JS/JSON total is {format_size(text_total)}; "
37
+ "review for embedded databases or generated content"
38
+ )
39
+ return warnings
40
+
41
+
42
+ def read_directory(path: Path) -> list[tuple[str, int]]:
43
+ return [
44
+ (file.relative_to(path).as_posix(), file.stat().st_size)
45
+ for file in sorted(path.rglob("*"))
46
+ if file.is_file() and ".git" not in file.relative_to(path).parts
47
+ ]
48
+
49
+
50
+ def read_zip(path: Path) -> tuple[list[tuple[str, int]], list[str], list[str]]:
51
+ errors: list[str] = []
52
+ warnings: list[str] = []
53
+ if path.stat().st_size > ZIP_LIMIT:
54
+ errors.append(
55
+ f"{path.name}: zip is {format_size(path.stat().st_size)}; hard limit is 10 MiB"
56
+ )
57
+ elif path.stat().st_size > ZIP_RECOMMENDED:
58
+ warnings.append(
59
+ f"{path.name}: zip is {format_size(path.stat().st_size)}; recommended target is 2 MiB"
60
+ )
61
+
62
+ with zipfile.ZipFile(path) as archive:
63
+ infos = [info for info in archive.infolist() if not info.is_dir()]
64
+ entries = [(info.filename, info.file_size) for info in infos]
65
+ return entries, errors, warnings
66
+
67
+
68
+ def main() -> int:
69
+ if len(sys.argv) != 2:
70
+ print("Usage: python3 audit_artifact.py <artifact-directory-or-zip>")
71
+ return 2
72
+
73
+ path = Path(sys.argv[1]).resolve()
74
+ if not path.exists():
75
+ print(f"ERROR: path not found: {path}")
76
+ return 2
77
+
78
+ errors: list[str] = []
79
+ warnings: list[str] = []
80
+ try:
81
+ if path.is_dir():
82
+ entries = read_directory(path)
83
+ elif zipfile.is_zipfile(path):
84
+ entries, zip_errors, zip_warnings = read_zip(path)
85
+ errors.extend(zip_errors)
86
+ warnings.extend(zip_warnings)
87
+ else:
88
+ print(f"ERROR: expected a directory or zip file: {path}")
89
+ return 2
90
+ except (OSError, zipfile.BadZipFile) as exc:
91
+ print(f"ERROR: cannot inspect {path}: {exc}")
92
+ return 2
93
+
94
+ warnings.extend(audit_entries(entries))
95
+
96
+ for message in errors:
97
+ print(f"ERROR: {message}")
98
+ for message in warnings:
99
+ print(f"WARN: {message}")
100
+
101
+ if errors:
102
+ print(f"FAILED: {len(errors)} error(s), {len(warnings)} warning(s)")
103
+ return 1
104
+ print(f"PASS: {len(entries)} file(s), {len(warnings)} warning(s)")
105
+ return 0
106
+
107
+
108
+ if __name__ == "__main__":
109
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "minitool-zip-builder",
3
+ "version": "1.6.0",
4
+ "format": "cursor-skill-zip-v1"
5
+ }
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ xhs-tool/
4
+ *.zip
5
+ .DS_Store
6
+ *.log
7
+ .vite/
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
8
+ />
9
+ <title>{{NAME}}</title>
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <!--
14
+ 容器不支持打开新页面,多视图请在单页内用 JS 切换(SPA)。
15
+ 每个 .page 是一个全屏子容器,通过 .is-active 控制显示。
16
+ -->
17
+ <section class="page is-active" data-page="home">
18
+ <header class="hero">
19
+ <div class="hero-icon">{{ICON}}</div>
20
+ <h1 class="hero-title">{{TITLE}}</h1>
21
+ <p class="hero-desc">{{DESC}}</p>
22
+ </header>
23
+
24
+ <div class="panel">
25
+ <label class="field">
26
+ <span class="field-label">你的名字</span>
27
+ <input id="nameInput" class="field-input" type="text" placeholder="输入后点下方按钮" />
28
+ </label>
29
+ <button id="goBtn" class="btn btn-primary" type="button">开始</button>
30
+ <p id="greet" class="result" hidden></p>
31
+ </div>
32
+
33
+ <footer class="foot">
34
+ <p>计数:<span id="count">0</span> 次</p>
35
+ <button id="resetBtn" class="btn btn-ghost" type="button">重置</button>
36
+ </footer>
37
+ </section>
38
+ </div>
39
+
40
+ <div class="toast" id="toast" hidden></div>
41
+
42
+ <!-- 脚本必须外置(容器 CSP 禁止内联脚本与行内事件)。
43
+ 开发/构建期保持 type="module",Vite 才能把 import 打包进单文件;
44
+ 打包时由脚手架自动重建为经典脚本(容器离线加载 module 解析不可靠)。 -->
45
+ <script type="module" src="./src/main.js"></script>
46
+ </body>
47
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{NAME}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "{{DESC}}",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build && xhs-minitool-creator pack",
10
+ "pack": "xhs-minitool-creator pack",
11
+ "validate": "xhs-minitool-creator validate",
12
+ "skill": "xhs-minitool-creator skill",
13
+ "preview": "vite preview --port 5174"
14
+ },
15
+ "devDependencies": {
16
+ "vite": "^6.3.5",
17
+ "xhs-minitool-creator": "^1.0.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.17.0"
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
2
+ <rect width="192" height="192" rx="42" fill="#FF2442"/>
3
+ <text x="96" y="118" font-size="72" text-anchor="middle" fill="#fff" font-family="-apple-system,sans-serif" font-weight="700">工</text>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
2
+ <rect width="192" height="192" rx="112" fill="#FF2442"/>
3
+ <text x="256" y="310" font-size="192" text-anchor="middle" fill="#fff" font-family="-apple-system,sans-serif" font-weight="700">工</text>
4
+ </svg>
@@ -0,0 +1,54 @@
1
+ /**
2
+ * localStorage 安全包装
3
+ *
4
+ * 容器内 localStorage 通常可用,但个别环境(隐私模式、存储被禁用)会直接抛异常。
5
+ * 未捕获会导致整个脚本中断、页面白屏。这里统一 try/catch,
6
+ * 失败时降级到内存 Map,保证功能不崩(数据在本次会话内有效)。
7
+ */
8
+
9
+ const memory = new Map();
10
+
11
+ function canUse() {
12
+ try {
13
+ const k = '__xmc_probe__';
14
+ window.localStorage.setItem(k, '1');
15
+ window.localStorage.removeItem(k);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ const usable = canUse();
23
+
24
+ export const safeStorage = {
25
+ /** @returns {string|null} */
26
+ get(key) {
27
+ try {
28
+ if (usable) return window.localStorage.getItem(key);
29
+ } catch { /* 降级 */ }
30
+ return memory.has(key) ? memory.get(key) : null;
31
+ },
32
+ set(key, value) {
33
+ try {
34
+ if (usable) {
35
+ window.localStorage.setItem(key, value);
36
+ return;
37
+ }
38
+ } catch { /* 降级 */ }
39
+ memory.set(key, value);
40
+ },
41
+ remove(key) {
42
+ try {
43
+ if (usable) {
44
+ window.localStorage.removeItem(key);
45
+ return;
46
+ }
47
+ } catch { /* 降级 */ }
48
+ memory.delete(key);
49
+ },
50
+ /** localStorage 是否真的可用(false 表示已降级到内存) */
51
+ persistent: usable,
52
+ };
53
+
54
+ export default safeStorage;
@@ -0,0 +1,81 @@
1
+ import './styles/app.css';
2
+ import { safeStorage } from './lib/storage.js';
3
+
4
+ /**
5
+ * 示例:一个最小可用的小工具页面。
6
+ * 演示三件事:
7
+ * 1. 事件用 addEventListener 绑定(容器禁止 onclick 等行内事件)
8
+ * 2. 本地存储用 safeStorage 包装(个别环境 localStorage 会抛错)
9
+ * 3. 多视图在单页内切换(容器不支持打开新页面)
10
+ */
11
+
12
+ const THEME_KEY = 'demo_theme';
13
+ const COUNT_KEY = 'demo_count';
14
+
15
+ /** 朴素但够用的 toast */
16
+ function toast(message) {
17
+ const el = document.getElementById('toast');
18
+ if (!el) return;
19
+ el.textContent = message;
20
+ el.hidden = false;
21
+ el.classList.add('is-show');
22
+ clearTimeout(toast._t);
23
+ toast._t = setTimeout(() => {
24
+ el.classList.remove('is-show');
25
+ el.hidden = true;
26
+ }, 1600);
27
+ }
28
+
29
+ function renderCount() {
30
+ const el = document.getElementById('count');
31
+ if (el) el.textContent = String(Number(safeStorage.get(COUNT_KEY) || 0));
32
+ }
33
+
34
+ function init() {
35
+ const input = document.getElementById('nameInput');
36
+ const goBtn = document.getElementById('goBtn');
37
+ const resetBtn = document.getElementById('resetBtn');
38
+ const greet = document.getElementById('greet');
39
+
40
+ // 恢复上次输入
41
+ const saved = safeStorage.get('demo_name');
42
+ if (saved && input) input.value = saved;
43
+
44
+ renderCount();
45
+
46
+ goBtn?.addEventListener('click', () => {
47
+ const name = (input?.value || '').trim();
48
+ if (!name) {
49
+ toast('先输入名字吧');
50
+ input?.focus();
51
+ return;
52
+ }
53
+ safeStorage.set('demo_name', name);
54
+
55
+ const next = Number(safeStorage.get(COUNT_KEY) || 0) + 1;
56
+ safeStorage.set(COUNT_KEY, String(next));
57
+ renderCount();
58
+
59
+ if (greet) {
60
+ greet.textContent = `你好,${name}!这是第 ${next} 次使用。`;
61
+ greet.hidden = false;
62
+ }
63
+ toast('已保存,下次打开还在');
64
+ });
65
+
66
+ resetBtn?.addEventListener('click', () => {
67
+ safeStorage.remove(COUNT_KEY);
68
+ safeStorage.remove('demo_name');
69
+ if (input) input.value = '';
70
+ if (greet) greet.hidden = true;
71
+ renderCount();
72
+ toast('已重置');
73
+ });
74
+
75
+ // 主题跟随系统(仅作演示,可删)
76
+ const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
77
+ document.body.dataset.theme = safeStorage.get(THEME_KEY) || (dark ? 'dark' : 'light');
78
+ }
79
+
80
+ // 经典脚本环境下 DOM 已就绪(脚本在 body 末尾),直接初始化即可
81
+ init();
@@ -0,0 +1,229 @@
1
+ /*
2
+ * 基线:Chrome 61 / Android 8.1(官方 Skill v1.6.0 css-compatibility.md)
3
+ * 原则:先写 Chrome 61 可用的基线层,再用 @supports / 能力检测做增强。
4
+ * 不使用:flex gap(改 margin)、aspect-ratio、clamp()、逻辑属性、
5
+ * :has()、Container Queries、dvh、color-mix() 等作为唯一实现。
6
+ */
7
+
8
+ :root {
9
+ --accent: #ff2442; /* 由脚手架按主题色注入 */
10
+ --bg: #f6f6f8;
11
+ --card: #ffffff;
12
+ --text: #1a1a1e;
13
+ --text-2: #6b6b76;
14
+ --border: rgba(0, 0, 0, 0.1);
15
+ --radius: 14px;
16
+
17
+ /* 平台在顶部叠加原生导航栏(左返回 / 右分享),不占布局高度,需自行让位。
18
+ PC 模拟器注入 --safe-area-inset-* 变量,真机用 env(),故组合书写。 */
19
+ --safe-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
20
+ --safe-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
21
+ }
22
+
23
+ body[data-theme='dark'] {
24
+ --bg: #121216;
25
+ --card: #1c1c22;
26
+ --text: #f2f2f5;
27
+ --text-2: #9a9aa6;
28
+ --border: rgba(255, 255, 255, 0.12);
29
+ }
30
+
31
+ * {
32
+ box-sizing: border-box;
33
+ margin: 0;
34
+ padding: 0;
35
+ -webkit-tap-highlight-color: transparent;
36
+ }
37
+
38
+ html {
39
+ /* 统一鼠标与触摸,避免点击延迟与误触 */
40
+ touch-action: manipulation;
41
+ }
42
+
43
+ body {
44
+ min-height: 100vh;
45
+ background: var(--bg);
46
+ color: var(--text);
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
48
+ 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
49
+ font-size: 16px;
50
+ line-height: 1.6;
51
+ -webkit-font-smoothing: antialiased;
52
+ -webkit-user-select: none;
53
+ user-select: none;
54
+ -webkit-touch-callout: none;
55
+ transition: background 0.2s, color 0.2s;
56
+ }
57
+
58
+ #app {
59
+ width: 100%;
60
+ max-width: 640px;
61
+ margin: 0 auto;
62
+ padding-top: calc(var(--safe-top) + 52px); /* 52px 为平台导航栏预留 */
63
+ padding-left: 16px;
64
+ padding-right: 16px;
65
+ padding-bottom: calc(var(--safe-bottom) + 24px);
66
+ }
67
+
68
+ /* ---------- 单页多视图 ---------- */
69
+ .page {
70
+ display: none;
71
+ min-height: 60vh;
72
+ }
73
+ .page.is-active {
74
+ display: block;
75
+ }
76
+
77
+ /* ---------- Hero ---------- */
78
+ .hero {
79
+ text-align: center;
80
+ padding-bottom: 20px;
81
+ }
82
+ .hero-icon {
83
+ font-size: 52px;
84
+ line-height: 1;
85
+ margin-bottom: 12px;
86
+ }
87
+ .hero-title {
88
+ font-size: 22px;
89
+ font-weight: 700;
90
+ letter-spacing: 0.2px;
91
+ }
92
+ .hero-desc {
93
+ margin-top: 8px;
94
+ font-size: 14px;
95
+ color: var(--text-2);
96
+ }
97
+
98
+ /* ---------- 卡片 ---------- */
99
+ .panel {
100
+ background: var(--card);
101
+ border: 1px solid var(--border);
102
+ border-radius: var(--radius);
103
+ padding: 18px;
104
+ margin-bottom: 16px;
105
+ }
106
+
107
+ .field {
108
+ display: block;
109
+ margin-bottom: 14px;
110
+ }
111
+ .field-label {
112
+ display: block;
113
+ font-size: 13px;
114
+ color: var(--text-2);
115
+ margin-bottom: 6px;
116
+ }
117
+ .field-input {
118
+ width: 100%;
119
+ height: 44px;
120
+ padding: 0 12px;
121
+ font-size: 16px; /* 低于 16px 在 iOS 上会触发自动缩放 */
122
+ color: var(--text);
123
+ background: var(--bg);
124
+ border: 1px solid var(--border);
125
+ border-radius: 10px;
126
+ outline: none;
127
+ -webkit-user-select: text; /* 输入框需要可选中 */
128
+ user-select: text;
129
+ }
130
+ .field-input:focus {
131
+ border-color: var(--accent);
132
+ }
133
+
134
+ /* ---------- 按钮 ---------- */
135
+ .btn {
136
+ display: block;
137
+ width: 100%;
138
+ height: 46px;
139
+ font-size: 16px;
140
+ font-weight: 600;
141
+ border: 1px solid transparent;
142
+ border-radius: 10px;
143
+ cursor: pointer;
144
+ transition: opacity 0.15s, transform 0.1s;
145
+ }
146
+ .btn:active {
147
+ opacity: 0.75;
148
+ transform: scale(0.99);
149
+ }
150
+ .btn-primary {
151
+ color: #fff;
152
+ background: var(--accent);
153
+ }
154
+ .btn-ghost {
155
+ color: var(--text-2);
156
+ background: transparent;
157
+ border-color: var(--border);
158
+ }
159
+
160
+ /* ---------- 结果 ---------- */
161
+ .result {
162
+ margin-top: 14px;
163
+ padding: 12px;
164
+ font-size: 15px;
165
+ text-align: center;
166
+ color: var(--accent);
167
+ background: rgba(255, 36, 66, 0.08);
168
+ border-radius: 10px;
169
+ }
170
+
171
+ /* ---------- 底部 ---------- */
172
+ .foot {
173
+ display: block;
174
+ padding: 4px 0 8px;
175
+ font-size: 13px;
176
+ color: var(--text-2);
177
+ text-align: center;
178
+ }
179
+ .foot p {
180
+ margin-bottom: 10px;
181
+ }
182
+
183
+ /* ---------- Toast ---------- */
184
+ .toast {
185
+ position: fixed;
186
+ left: 50%;
187
+ bottom: calc(var(--safe-bottom) + 40px);
188
+ z-index: 100;
189
+ max-width: 80%;
190
+ padding: 10px 18px;
191
+ font-size: 14px;
192
+ color: #fff;
193
+ background: rgba(0, 0, 0, 0.82);
194
+ border-radius: 999px;
195
+ opacity: 0;
196
+ transform: translate(-50%, 8px);
197
+ transition: opacity 0.18s, transform 0.18s;
198
+ pointer-events: none;
199
+ }
200
+ .toast.is-show {
201
+ opacity: 1;
202
+ transform: translate(-50%, 0);
203
+ }
204
+
205
+ /* 增强层:支持毛玻璃时才启用,不支持时用实色(基线已提供) */
206
+ @supports ((-webkit-backdrop-filter: blur(12px)) or (backdrop-filter: blur(12px))) {
207
+ .toast {
208
+ -webkit-backdrop-filter: blur(8px);
209
+ backdrop-filter: blur(8px);
210
+ }
211
+ }
212
+
213
+ /* ---------- 小屏适配 ---------- */
214
+ @media (max-width: 360px) {
215
+ #app {
216
+ padding-left: 12px;
217
+ padding-right: 12px;
218
+ }
219
+ .hero-title {
220
+ font-size: 20px;
221
+ }
222
+ }
223
+
224
+ /* 鼠标设备才启用 hover 效果(触摸端关键操作不能只靠 hover 出现) */
225
+ @media (hover: hover) {
226
+ .btn:hover {
227
+ opacity: 0.9;
228
+ }
229
+ }
@@ -0,0 +1,9 @@
1
+ import { defineMinitoolConfig } from 'xhs-minitool-creator/vite';
2
+
3
+ // defineMinitoolConfig 已内置官方要求的全部构建约束,
4
+ // 其中最重要的是 build.target = ['es2017','chrome61'] —— 缺少它
5
+ // 会把 `??` 等 ES2020 语法保留进产物,在低端 WebView 上白屏且无报错。
6
+ export default defineMinitoolConfig({
7
+ // 需要额外覆盖时在此添加,例如:
8
+ // server: { port: 5173 },
9
+ });