- {antDesignData.map((group, gi) => (
+ {this.props.data.map((group, gi) => (
@@ -142,8 +143,7 @@ class AntDesignColors extends PureComponent<{ onColorClick: (e: any) => void }>
}
}
-// MaterialDesign子组件
-class MaterialDesignColors extends PureComponent<{ onColorClick: (e: any) => void }> {
+class MaterialDesignColors extends PureComponent<{ onColorClick: (e: any) => void; data: MaterialColorCategory[] }> {
render() {
return (
- {materialDesignData.map((group, gi) => (
+ {this.props.data.map((group, gi) => (
{group.title}
@@ -170,7 +170,8 @@ class MaterialDesignColors extends PureComponent<{ onColorClick: (e: any) => voi
style={{ backgroundColor: color }}
onClick={this.props.onColorClick}
>
- {color.substr(1)}
+ {/* 色阶按浅→深排列: 前 5 档是浅色档配深字, 其余深色档配白字 */}
+ {color.substr(1)}
) :
}
@@ -184,7 +185,6 @@ class MaterialDesignColors extends PureComponent<{ onColorClick: (e: any) => voi
}
}
-// 主组件
interface UIPalettesState {
ui: string;
}
@@ -208,16 +208,22 @@ class UIPalettesPage extends Component<{ onColorClick: (e: any) => void }, UIPal
}
getTabContent() {
+ const flatData = flatUIData as unknown as FlatUIColorCategory[];
+ const fluentDataT = fluentData as unknown as UIColorCategory[];
+ const openColorDataT = openColorData as unknown as UIColorCategory[];
+ const antDesignDataT = antDesignData as unknown as UIColorCategory[];
+ const materialDesignDataT = materialDesignData as unknown as MaterialColorCategory[];
switch (this.state.ui) {
- case "flat": return
;
- case "fluent": return
;
- case "open": return
;
- case "antd": return
;
- case "material": return
;
+ case "flat": return
;
+ case "fluent": return
;
+ case "open": return
;
+ case "antd": return
;
+ case "material": return
;
default: return false;
}
}
+ // Tab 切换只更新 state, 离开页面时才持久化: 避免每次点 Tab 都写一次 db
componentWillUnmount() {
const saved = db.get("uicolor") || { _id: "uicolor", ui: "flat" };
if (saved.ui !== this.state.ui) {
diff --git a/plugins/color-helper/pnpm-lock.yaml b/plugins/color-helper/pnpm-lock.yaml
new file mode 100644
index 000000000..78b8e7c64
--- /dev/null
+++ b/plugins/color-helper/pnpm-lock.yaml
@@ -0,0 +1,1685 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@emotion/cache':
+ specifier: ^11.14.0
+ version: 11.14.0
+ '@emotion/css':
+ specifier: ^11.13.5
+ version: 11.13.5
+ '@emotion/react':
+ specifier: ^11.11.1
+ version: 11.14.0(@types/react@19.3.0)(react@19.3.0)
+ '@emotion/styled':
+ specifier: ^11.11.0
+ version: 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+ '@jaames/iro':
+ specifier: ^5.5.2
+ version: 5.5.2
+ '@mui/icons-material':
+ specifier: ^7.3.11
+ version: 7.3.11(@mui/material@7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+ '@mui/material':
+ specifier: ^7.3.11
+ version: 7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ chroma-js:
+ specifier: ^3.2.0
+ version: 3.2.0
+ react:
+ specifier: ^19.3.0
+ version: 19.3.0
+ react-dom:
+ specifier: ^19.3.0
+ version: 19.3.0(react@19.3.0)
+ devDependencies:
+ '@types/chroma-js':
+ specifier: ^3.1.2
+ version: 3.1.2
+ '@types/node':
+ specifier: ^26.6.4
+ version: 26.6.4
+ '@types/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ '@vitejs/plugin-react':
+ specifier: ^6.1.1
+ version: 6.1.1(vite@8.3.2(@types/node@26.6.4))
+ happy-dom:
+ specifier: ^20.14.5
+ version: 20.14.5
+ typescript:
+ specifier: ^5.1.6
+ version: 5.9.3
+ vite:
+ specifier: ^8.3.2
+ version: 8.3.2(@types/node@26.6.4)
+ vitest:
+ specifier: 4.1.11
+ version: 4.1.11(@types/node@26.6.4)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4))
+
+packages:
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.9':
+ resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.8':
+ resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@emotion/babel-plugin@11.13.5':
+ resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
+
+ '@emotion/cache@11.14.0':
+ resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
+
+ '@emotion/css@11.13.5':
+ resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==}
+
+ '@emotion/hash@0.9.2':
+ resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
+
+ '@emotion/is-prop-valid@1.4.0':
+ resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
+
+ '@emotion/memoize@0.9.0':
+ resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
+
+ '@emotion/react@11.14.0':
+ resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@emotion/serialize@1.3.3':
+ resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
+
+ '@emotion/sheet@1.4.0':
+ resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
+
+ '@emotion/styled@11.14.1':
+ resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==}
+ peerDependencies:
+ '@emotion/react': ^11.0.0-rc.0
+ '@types/react': '*'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@emotion/unitless@0.10.0':
+ resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
+
+ '@emotion/use-insertion-effect-with-fallbacks@1.2.0':
+ resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@emotion/utils@1.4.2':
+ resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
+
+ '@emotion/weak-memoize@0.4.0':
+ resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
+
+ '@irojs/iro-core@1.2.1':
+ resolution: {integrity: sha1-q0XPCJlxf654XvlmG4K7b3jNN0g=}
+
+ '@jaames/iro@5.5.2':
+ resolution: {integrity: sha1-FTAcGQgLmFrt4GxfB4Z6og0G5nk=}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@mui/core-downloads-tracker@7.3.11':
+ resolution: {integrity: sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==}
+
+ '@mui/icons-material@7.3.11':
+ resolution: {integrity: sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@mui/material': ^7.3.11
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@mui/material@7.3.11':
+ resolution: {integrity: sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@emotion/react': ^11.5.0
+ '@emotion/styled': ^11.3.0
+ '@mui/material-pigment-css': ^7.3.11
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/react':
+ optional: true
+ '@emotion/styled':
+ optional: true
+ '@mui/material-pigment-css':
+ optional: true
+ '@types/react':
+ optional: true
+
+ '@mui/private-theming@7.3.11':
+ resolution: {integrity: sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@mui/styled-engine@7.3.10':
+ resolution: {integrity: sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@emotion/react': ^11.4.1
+ '@emotion/styled': ^11.3.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/react':
+ optional: true
+ '@emotion/styled':
+ optional: true
+
+ '@mui/system@7.3.11':
+ resolution: {integrity: sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@emotion/react': ^11.5.0
+ '@emotion/styled': ^11.3.0
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/react':
+ optional: true
+ '@emotion/styled':
+ optional: true
+ '@types/react':
+ optional: true
+
+ '@mui/types@7.4.12':
+ resolution: {integrity: sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==}
+ peerDependencies:
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@mui/utils@7.3.11':
+ resolution: {integrity: sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@oxc-project/types@0.152.0':
+ resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
+
+ '@popperjs/core@2.11.8':
+ resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+
+ '@rolldown/binding-android-arm-eabi@1.2.12':
+ resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@rolldown/binding-android-arm64@1.2.12':
+ resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.2.12':
+ resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.2.12':
+ resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.2.12':
+ resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.12':
+ resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.2.12':
+ resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.2.12':
+ resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.2.12':
+ resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.2.12':
+ resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.2.12':
+ resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.2.12':
+ resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.2.12':
+ resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-win32-arm64-msvc@1.2.12':
+ resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.2.12':
+ resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/chroma-js@3.1.2':
+ resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@26.6.4':
+ resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==}
+
+ '@types/parse-json@4.0.2':
+ resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@19.3.0':
+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
+ peerDependencies:
+ '@types/react': ^19.3.0
+
+ '@types/react-transition-group@4.4.12':
+ resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
+ peerDependencies:
+ '@types/react': '*'
+
+ '@types/react@19.3.0':
+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
+ '@vitejs/plugin-react@6.1.1':
+ resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ oxc-transform-react: ^0.145.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ oxc-transform-react:
+ optional: true
+
+ '@vitest/expect@4.1.11':
+ resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
+
+ '@vitest/mocker@4.1.11':
+ resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.11':
+ resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
+
+ '@vitest/runner@4.1.11':
+ resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
+
+ '@vitest/snapshot@4.1.11':
+ resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
+
+ '@vitest/spy@4.1.11':
+ resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
+
+ '@vitest/utils@4.1.11':
+ resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ babel-plugin-macros@3.1.0:
+ resolution: {integrity: sha1-nvbcdN65NLTbNE3Jc+6FHRSMUME=}
+ engines: {node: '>=10', npm: '>=6'}
+
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=}
+ engines: {node: '>=6'}
+
+ chai@6.3.0:
+ resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
+ engines: {node: '>=18'}
+
+ chroma-js@3.2.0:
+ resolution: {integrity: sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ convert-source-map@1.9.0:
+ resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cosmiconfig@7.1.0:
+ resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
+ engines: {node: '>=10'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dom-helpers@5.2.1:
+ resolution: {integrity: sha1-2UAFNrK/giWtmP4FLgKUUaxA6QI=}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ error-ex@1.3.4:
+ resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-module-lexer@2.3.2:
+ resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=}
+ engines: {node: '>=10'}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ find-root@1.1.0:
+ resolution: {integrity: sha1-q8/Iunb3CMQql7PWhbfpRQv7nOQ=}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ happy-dom@20.14.5:
+ resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
+ engines: {node: '>=20.0.0'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha1-7OCsr3HWLClpwuxZ/v9CpLGoW0U=}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ is-arrayish@0.2.1:
+ resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
+
+ is-core-module@2.17.0:
+ resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
+ engines: {node: '>= 0.4'}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-parse-even-better-errors@2.3.1:
+ resolution: {integrity: sha1-fEeAWpQxmSjgV3dAXcEuH3pO4C0=}
+
+ lightningcss-android-arm64@1.33.0:
+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.33.0:
+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.33.0:
+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.33.0:
+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.33.0:
+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
+ engines: {node: '>= 12.0.0'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha1-7KKE910pZQeTCdwK2SVauy68FjI=}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=}
+
+ nanoid@3.3.19:
+ resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=}
+ engines: {node: '>=0.10.0'}
+
+ obug@2.2.1:
+ resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
+ engines: {node: '>=12.20.0'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=}
+ engines: {node: '>=6'}
+
+ parse-json@5.2.0:
+ resolution: {integrity: sha1-x2/Gbe5UIxyWKyK8yKcs8vmXU80=}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha1-+8EUtgykKzDZ2vWFjkvWi77bZzU=}
+
+ path-type@4.0.0:
+ resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+ engines: {node: '>=8'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.28:
+ resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ preact@10.29.8:
+ resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==}
+ peerDependencies:
+ preact-render-to-string: '>=5'
+ peerDependenciesMeta:
+ preact-render-to-string:
+ optional: true
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ react-dom@19.3.0:
+ resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
+ peerDependencies:
+ react: ^19.3.0
+
+ react-is@16.13.1:
+ resolution: {integrity: sha1-eJcppNw23imZ3BVt1sHZwYzqVqQ=}
+
+ react-is@19.3.0:
+ resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==}
+
+ react-transition-group@4.4.5:
+ resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+ peerDependencies:
+ react: '>=16.6.0'
+ react-dom: '>=16.6.0'
+
+ react@19.3.0:
+ resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ rolldown@1.2.12:
+ resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ scheduler@0.28.0:
+ resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.5.7:
+ resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=}
+ engines: {node: '>=0.10.0'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.3.0:
+ resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
+
+ stylis@4.2.0:
+ resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.3.1:
+ resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.2.0:
+ resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
+ engines: {node: '>=14.0.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
+ vite@8.3.2:
+ resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.7.1
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.11:
+ resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.11
+ '@vitest/browser-preview': 4.1.11
+ '@vitest/browser-webdriverio': 4.1.11
+ '@vitest/coverage-istanbul': 4.1.11
+ '@vitest/coverage-v8': 4.1.11
+ '@vitest/ui': 4.1.11
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha1-X6GnYjhn/xr2yj3HKta4pCCL66c=}
+ engines: {node: '>=12'}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ ws@8.22.0:
+ resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ yaml@1.10.3:
+ resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
+ engines: {node: '>= 6'}
+
+snapshots:
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.9':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.8':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@emotion/babel-plugin@11.13.5':
+ dependencies:
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@emotion/hash': 0.9.2
+ '@emotion/memoize': 0.9.0
+ '@emotion/serialize': 1.3.3
+ babel-plugin-macros: 3.1.0
+ convert-source-map: 1.9.0
+ escape-string-regexp: 4.0.0
+ find-root: 1.1.0
+ source-map: 0.5.7
+ stylis: 4.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/cache@11.14.0':
+ dependencies:
+ '@emotion/memoize': 0.9.0
+ '@emotion/sheet': 1.4.0
+ '@emotion/utils': 1.4.2
+ '@emotion/weak-memoize': 0.4.0
+ stylis: 4.2.0
+
+ '@emotion/css@11.13.5':
+ dependencies:
+ '@emotion/babel-plugin': 11.13.5
+ '@emotion/cache': 11.14.0
+ '@emotion/serialize': 1.3.3
+ '@emotion/sheet': 1.4.0
+ '@emotion/utils': 1.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/hash@0.9.2': {}
+
+ '@emotion/is-prop-valid@1.4.0':
+ dependencies:
+ '@emotion/memoize': 0.9.0
+
+ '@emotion/memoize@0.9.0': {}
+
+ '@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/babel-plugin': 11.13.5
+ '@emotion/cache': 11.14.0
+ '@emotion/serialize': 1.3.3
+ '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.3.0)
+ '@emotion/utils': 1.4.2
+ '@emotion/weak-memoize': 0.4.0
+ hoist-non-react-statics: 3.3.2
+ react: 19.3.0
+ optionalDependencies:
+ '@types/react': 19.3.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/serialize@1.3.3':
+ dependencies:
+ '@emotion/hash': 0.9.2
+ '@emotion/memoize': 0.9.0
+ '@emotion/unitless': 0.10.0
+ '@emotion/utils': 1.4.2
+ csstype: 3.2.3
+
+ '@emotion/sheet@1.4.0': {}
+
+ '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/babel-plugin': 11.13.5
+ '@emotion/is-prop-valid': 1.4.0
+ '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)
+ '@emotion/serialize': 1.3.3
+ '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.3.0)
+ '@emotion/utils': 1.4.2
+ react: 19.3.0
+ optionalDependencies:
+ '@types/react': 19.3.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/unitless@0.10.0': {}
+
+ '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.3.0)':
+ dependencies:
+ react: 19.3.0
+
+ '@emotion/utils@1.4.2': {}
+
+ '@emotion/weak-memoize@0.4.0': {}
+
+ '@irojs/iro-core@1.2.1': {}
+
+ '@jaames/iro@5.5.2':
+ dependencies:
+ '@irojs/iro-core': 1.2.1
+ preact: 10.29.8
+ transitivePeerDependencies:
+ - preact-render-to-string
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@mui/core-downloads-tracker@7.3.11': {}
+
+ '@mui/icons-material@7.3.11(@mui/material@7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@mui/material': 7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ react: 19.3.0
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@mui/material@7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@mui/core-downloads-tracker': 7.3.11
+ '@mui/system': 7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+ '@mui/types': 7.4.12(@types/react@19.3.0)
+ '@mui/utils': 7.3.11(@types/react@19.3.0)(react@19.3.0)
+ '@popperjs/core': 2.11.8
+ '@types/react-transition-group': 4.4.12(@types/react@19.3.0)
+ clsx: 2.1.1
+ csstype: 3.2.3
+ prop-types: 15.8.1
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ react-is: 19.3.0
+ react-transition-group: 4.4.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ optionalDependencies:
+ '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)
+ '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+ '@types/react': 19.3.0
+
+ '@mui/private-theming@7.3.11(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@mui/utils': 7.3.11(@types/react@19.3.0)(react@19.3.0)
+ prop-types: 15.8.1
+ react: 19.3.0
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@mui/styled-engine@7.3.10(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/cache': 11.14.0
+ '@emotion/serialize': 1.3.3
+ '@emotion/sheet': 1.4.0
+ csstype: 3.2.3
+ prop-types: 15.8.1
+ react: 19.3.0
+ optionalDependencies:
+ '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)
+ '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+
+ '@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@mui/private-theming': 7.3.11(@types/react@19.3.0)(react@19.3.0)
+ '@mui/styled-engine': 7.3.10(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)
+ '@mui/types': 7.4.12(@types/react@19.3.0)
+ '@mui/utils': 7.3.11(@types/react@19.3.0)(react@19.3.0)
+ clsx: 2.1.1
+ csstype: 3.2.3
+ prop-types: 15.8.1
+ react: 19.3.0
+ optionalDependencies:
+ '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)
+ '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)
+ '@types/react': 19.3.0
+
+ '@mui/types@7.4.12(@types/react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@mui/utils@7.3.11(@types/react@19.3.0)(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@mui/types': 7.4.12(@types/react@19.3.0)
+ '@types/prop-types': 15.7.15
+ clsx: 2.1.1
+ prop-types: 15.8.1
+ react: 19.3.0
+ react-is: 19.3.0
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@oxc-project/types@0.152.0': {}
+
+ '@popperjs/core@2.11.8': {}
+
+ '@rolldown/binding-android-arm-eabi@1.2.12':
+ optional: true
+
+ '@rolldown/binding-android-arm64@1.2.12':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.2.12':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.2.12':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.2.12':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.2.12':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.2.12':
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.2.12':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.2.12':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/chroma-js@3.1.2': {}
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@26.6.4':
+ dependencies:
+ undici-types: 8.9.0
+
+ '@types/parse-json@4.0.2': {}
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react-transition-group@4.4.12(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react@19.3.0':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/whatwg-mimetype@3.0.2': {}
+
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 26.6.4
+
+ '@vitejs/plugin-react@6.1.1(vite@8.3.2(@types/node@26.6.4))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.3.2(@types/node@26.6.4)
+
+ '@vitest/expect@4.1.11':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.11
+ '@vitest/utils': 4.1.11
+ chai: 6.3.0
+ tinyrainbow: 3.2.0
+
+ '@vitest/mocker@4.1.11(vite@8.3.2(@types/node@26.6.4))':
+ dependencies:
+ '@vitest/spy': 4.1.11
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.3.2(@types/node@26.6.4)
+
+ '@vitest/pretty-format@4.1.11':
+ dependencies:
+ tinyrainbow: 3.2.0
+
+ '@vitest/runner@4.1.11':
+ dependencies:
+ '@vitest/utils': 4.1.11
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.11':
+ dependencies:
+ '@vitest/pretty-format': 4.1.11
+ '@vitest/utils': 4.1.11
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.11': {}
+
+ '@vitest/utils@4.1.11':
+ dependencies:
+ '@vitest/pretty-format': 4.1.11
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.2.0
+
+ assertion-error@2.0.1: {}
+
+ babel-plugin-macros@3.1.0:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ cosmiconfig: 7.1.0
+ resolve: 1.22.12
+
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 26.6.4
+
+ callsites@3.1.0: {}
+
+ chai@6.3.0: {}
+
+ chroma-js@3.2.0: {}
+
+ clsx@2.1.1: {}
+
+ convert-source-map@1.9.0: {}
+
+ convert-source-map@2.0.0: {}
+
+ cosmiconfig@7.1.0:
+ dependencies:
+ '@types/parse-json': 4.0.2
+ import-fresh: 3.3.1
+ parse-json: 5.2.0
+ path-type: 4.0.0
+ yaml: 1.10.3
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ detect-libc@2.1.2: {}
+
+ dom-helpers@5.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ csstype: 3.2.3
+
+ entities@7.0.1: {}
+
+ error-ex@1.3.4:
+ dependencies:
+ is-arrayish: 0.2.1
+
+ es-errors@1.3.0: {}
+
+ es-module-lexer@2.3.2: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ expect-type@1.4.0: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ find-root@1.1.0: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ happy-dom@20.14.5:
+ dependencies:
+ '@types/node': 26.6.4
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.2
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.22.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hoist-non-react-statics@3.3.2:
+ dependencies:
+ react-is: 16.13.1
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ is-arrayish@0.2.1: {}
+
+ is-core-module@2.17.0:
+ dependencies:
+ hasown: 2.0.4
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json-parse-even-better-errors@2.3.1: {}
+
+ lightningcss-android-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.33.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.33.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ optional: true
+
+ lightningcss@1.33.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
+
+ lines-and-columns@1.2.4: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.19: {}
+
+ object-assign@4.1.1: {}
+
+ obug@2.2.1: {}
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse-json@5.2.0:
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ error-ex: 1.3.4
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 1.2.4
+
+ path-parse@1.0.7: {}
+
+ path-type@4.0.0: {}
+
+ pathe@2.0.3: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ postcss@8.5.28:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ preact@10.29.8: {}
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ react-dom@19.3.0(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ scheduler: 0.28.0
+
+ react-is@16.13.1: {}
+
+ react-is@19.3.0: {}
+
+ react-transition-group@4.4.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ dom-helpers: 5.2.1
+ loose-envify: 1.4.0
+ prop-types: 15.8.1
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+
+ react@19.3.0: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.17.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ rolldown@1.2.12:
+ dependencies:
+ '@oxc-project/types': 0.152.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm-eabi': 1.2.12
+ '@rolldown/binding-android-arm64': 1.2.12
+ '@rolldown/binding-darwin-arm64': 1.2.12
+ '@rolldown/binding-darwin-x64': 1.2.12
+ '@rolldown/binding-freebsd-x64': 1.2.12
+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.12
+ '@rolldown/binding-linux-arm64-gnu': 1.2.12
+ '@rolldown/binding-linux-arm64-musl': 1.2.12
+ '@rolldown/binding-linux-ppc64-gnu': 1.2.12
+ '@rolldown/binding-linux-s390x-gnu': 1.2.12
+ '@rolldown/binding-linux-x64-gnu': 1.2.12
+ '@rolldown/binding-linux-x64-musl': 1.2.12
+ '@rolldown/binding-openharmony-arm64': 1.2.12
+ '@rolldown/binding-win32-arm64-msvc': 1.2.12
+ '@rolldown/binding-win32-x64-msvc': 1.2.12
+
+ scheduler@0.28.0: {}
+
+ siginfo@2.0.0: {}
+
+ source-map-js@1.2.2: {}
+
+ source-map@0.5.7: {}
+
+ stackback@0.0.2: {}
+
+ std-env@4.3.0: {}
+
+ stylis@4.2.0: {}
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.3.1: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ tinyrainbow@3.2.0: {}
+
+ typescript@5.9.3: {}
+
+ undici-types@8.9.0: {}
+
+ vite@8.3.2(@types/node@26.6.4):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.7
+ postcss: 8.5.28
+ rolldown: 1.2.12
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 26.6.4
+ fsevents: 2.3.3
+
+ vitest@4.1.11(@types/node@26.6.4)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)):
+ dependencies:
+ '@vitest/expect': 4.1.11
+ '@vitest/mocker': 4.1.11(vite@8.3.2(@types/node@26.6.4))
+ '@vitest/pretty-format': 4.1.11
+ '@vitest/runner': 4.1.11
+ '@vitest/snapshot': 4.1.11
+ '@vitest/spy': 4.1.11
+ '@vitest/utils': 4.1.11
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.2.1
+ pathe: 2.0.3
+ picomatch: 4.0.7
+ std-env: 4.3.0
+ tinybench: 2.9.0
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.2.0
+ vite: 8.3.2(@types/node@26.6.4)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 26.6.4
+ happy-dom: 20.14.5
+ transitivePeerDependencies:
+ - msw
+
+ whatwg-mimetype@3.0.0: {}
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ ws@8.22.0: {}
+
+ yaml@1.10.3: {}
diff --git a/plugins/color-helper/public/plugin.json b/plugins/color-helper/public/plugin.json
index 98e319c44..38e836304 100644
--- a/plugins/color-helper/public/plugin.json
+++ b/plugins/color-helper/public/plugin.json
@@ -1,9 +1,8 @@
{
"name": "color-helper",
"title": "颜色助手",
- "pluginName": "颜色助手",
- "description": "颜色、AI 配色、图片色卡、UI 色卡、传统色、渐变色、收藏颜色",
- "version": "1.0.0",
+ "description": "颜色、UI 色卡、传统色、渐变色、图片色卡、收藏颜色、AI 配色",
+ "version": "1.1.0",
"ai": true,
"main": "index.html",
"logo": "logo.png",
@@ -23,8 +22,8 @@
"颜色",
{
"type": "regex",
- "maxLength": 50,
- "match": "/^(?:#[a-f0-9]{3}|#?[a-f0-9]{6}|#[a-f0-9]{8});?$|^(?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})$|^rgba?\\((?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})|^hs[liv]a?\\((?:360|(?:(?:3[0-5]\\d|[1-2]\\d{2}|\\d{1,2})(?:\\.\\d{1,15})?))(?:deg)?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?/i",
+ "minLength": 4,
+ "match": "/^(?:#[a-fA-F0-9]{3}|#[a-fA-F0-9]{6}|#[a-fA-F0-9]{8}|(?=[a-fA-F0-9]*[0-9])(?=[a-fA-F0-9]*[a-fA-F])[a-fA-F0-9]{6});?$|^(?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})$|^[rR][gG][bB][aA]?\\(\\s*(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:\\s*,\\s*(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})){2}(?:\\s*,\\s*(?:0|1|0?\\.\\d+))?\\s*\\)$|^[hH][sS][lLiIvV][aA]?\\(\\s*(?:360|3[0-5]\\d|[1-2]?\\d{1,2})(?:\\.\\d+)?(?:[dD][eE][gG])?\\s*,\\s*(?:100|\\d{1,2})(?:\\.\\d+)?%?\\s*,\\s*(?:100|\\d{1,2})(?:\\.\\d+)?%?(?:\\s*,\\s*(?:0|1|0?\\.\\d+))?\\s*\\)$/",
"label": "颜色"
}
]
@@ -85,14 +84,8 @@
"code": "ai",
"explain": "AI 配色",
"cmds": [
- "AI 配色",
- {
- "type": "regex",
- "maxLength": 50,
- "match": "/^(?:#[a-f0-9]{3}|#?[a-f0-9]{6}|#[a-f0-9]{8});?$|^(?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})$|^rgba?\\((?:(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:, *| +)){2}(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})|^hs[liv]a?\\((?:360|(?:(?:3[0-5]\\d|[1-2]\\d{2}|\\d{1,2})(?:\\.\\d{1,15})?))(?:deg)?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?(?:, *| +)(?:100|\\d{1,2}(?:\\.\\d{1,15})?)%?/i",
- "label": "AI 配色"
- }
+ "AI 配色"
]
}
]
-}
\ No newline at end of file
+}
diff --git a/plugins/color-helper/tests/collect.test.ts b/plugins/color-helper/tests/collect.test.ts
new file mode 100644
index 000000000..0059b2f72
--- /dev/null
+++ b/plugins/color-helper/tests/collect.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { db, dbStorage } from '../utils/platform';
+import { COLLECT_SORT_KEY, collectDocId, normalizeHex, saveCollectedColor } from '../utils/collect';
+
+/**
+ * 收藏颜色的公共写入 (utils/collect.ts)
+ *
+ * 与 CollectColorsPage 共用同一份 db 文档形状 —— 这里锁住的正是
+ * "两个入口必须写出同一种文档" 这条约束: 改了形状收藏页就认不出来了。
+ */
+
+beforeEach(() => {
+ localStorage.clear();
+ delete (window as any).platform;
+});
+
+describe('collectDocId', () => {
+ it('色值转小写并去掉 "#"', () => {
+ expect(collectDocId('#FF0000')).toBe('color/ff0000');
+ expect(collectDocId('ff0000')).toBe('color/ff0000');
+ });
+});
+
+describe('normalizeHex', () => {
+ it('补齐并统一成大写的 #RRGGBB', () => {
+ expect(normalizeHex('f00')).toBe('#FF0000');
+ expect(normalizeHex('#f00')).toBe('#FF0000');
+ expect(normalizeHex(' #FF0000 ')).toBe('#FF0000');
+ expect(normalizeHex('rgb(255, 0, 0)')).toBe('#FF0000');
+ expect(normalizeHex('hsl(0, 100%, 50%)')).toBe('#FF0000');
+ });
+
+ it('半透明色归一化成 8 位 #RRGGBBAA', () => {
+ expect(normalizeHex('#ff000080')).toBe('#FF000080');
+ expect(normalizeHex('rgba(255, 0, 0, 0.5)')).toBe('#FF000080');
+ });
+
+ it('非法色值返回 null', () => {
+ expect(normalizeHex('')).toBeNull();
+ expect(normalizeHex(' ')).toBeNull();
+ expect(normalizeHex('not-a-color')).toBeNull();
+ expect(normalizeHex(undefined as unknown as string)).toBeNull();
+ });
+});
+
+describe('saveCollectedColor', () => {
+ it('写入收藏页认得的文档形状', () => {
+ expect(saveCollectedColor('#FF0000')).toBe(true);
+
+ // dark 走与收藏页同一个 isDarkColor 判据 —— #FF0000 亮度高于阈值, 判为不深
+ expect(db.get('color/ff0000')).toMatchObject({
+ _id: 'color/ff0000',
+ name: '',
+ color: '#FF0000',
+ dark: false,
+ });
+
+ saveCollectedColor('#000000');
+ expect(db.get('color/000000')).toMatchObject({ color: '#000000', dark: true });
+ });
+
+ it('同一颜色只存一条, 重复收藏返回 false', () => {
+ expect(saveCollectedColor('#FF0000')).toBe(true);
+ // 大小写/格式不同也算同一个颜色
+ expect(saveCollectedColor('rgb(255, 0, 0)')).toBe(false);
+ expect(db.allDocs('color/')).toHaveLength(1);
+ });
+
+ it('非法色值不写库', () => {
+ expect(saveCollectedColor('什么')).toBe(false);
+ expect(db.allDocs('color/')).toEqual([]);
+ });
+
+ it('把新收藏追加进 collectsort, 收藏页才会在末尾显示它', () => {
+ db.put({ _id: 'color/000000', name: '', color: '#000000', dark: true });
+ dbStorage.setItem(COLLECT_SORT_KEY, ['color/000000']);
+
+ saveCollectedColor('#FF0000');
+
+ expect(dbStorage.getItem(COLLECT_SORT_KEY)).toEqual(['color/000000', 'color/ff0000']);
+ });
+
+ it('补上 db 里已有、索引里缺失的 id, 避免它们永远排在末尾', () => {
+ db.put({ _id: 'color/000000', name: '', color: '#000000', dark: true });
+
+ saveCollectedColor('#FF0000');
+
+ expect(dbStorage.getItem(COLLECT_SORT_KEY)).toEqual(['color/000000', 'color/ff0000']);
+ });
+
+ it('collectsort 存了非数组时按空索引重建, 不抛错', () => {
+ dbStorage.setItem(COLLECT_SORT_KEY, 'broken');
+
+ expect(saveCollectedColor('#FF0000')).toBe(true);
+ expect(dbStorage.getItem(COLLECT_SORT_KEY)).toEqual(['color/ff0000']);
+ });
+});
diff --git a/plugins/color-helper/tests/color-parsing.test.ts b/plugins/color-helper/tests/color-parsing.test.ts
new file mode 100644
index 000000000..295f2268c
--- /dev/null
+++ b/plugins/color-helper/tests/color-parsing.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest';
+import { parseColorInput as parseStrict, parseColorByType } from '../pages/ColorPage';
+import { parseColorInput as parseLoose } from '../pages/AIPalettePage';
+
+/**
+ * 一个项目里有两套颜色解析, 行为不同 —— 这是**已知设计分歧**, 不是 bug:
+ *
+ * | 位置 | 实现 | 特点 |
+ * |--------------------------|-------------------|--------------------------------------------|
+ * | pages/ColorPage.tsx | 逐格式正则 | 严格; 支持 hex/rgb/hsl/hsi/hsv/cmyk/lab |
+ * | pages/AIPalettePage.tsx | 直接 chroma(str) | 宽松; 接受 chroma 认识的任何写法 |
+ *
+ * 测试记录**当前**行为, 不做"修正"; 若将来要统一, 应作为独立一轮讨论。
+ */
+
+describe('ColorPage.parseColorInput(严格)', () => {
+ it.each([
+ ['#fff', '#ffffff'],
+ ['#ffffff', '#ffffff'],
+ ['#FF0000', '#ff0000'],
+ ['ff0000', '#ff0000'],
+ ['rgb(255, 0, 0)', '#ff0000'],
+ ['hsl(120, 50%, 50%)', '#40bf40'],
+ ['hsi(120, 50%, 50%)', '#40ff40'],
+ ['hsv(120, 50%, 50%)', '#408040'],
+ ['lab(50, 40, 30)', '#bf5846'],
+ ])('接受 %s → %s', (input, expected) => {
+ expect(parseStrict(input)?.hex()).toBe(expected);
+ });
+
+ it.each(['not-a-color', '#ggg', '', '#ff', '#12345', '255, 0, 0', 'cmyk(0, 100, 100, 0)'])(
+ '拒绝 %s',
+ (input) => {
+ expect(parseStrict(input)).toBeNull();
+ },
+ );
+
+ it('8 位 hex 丢弃前两位 alpha(#11223344 → #223344)', () => {
+ expect(parseStrict('#11223344')?.hex()).toBe('#223344');
+ });
+
+ it('短于 4 字符的输入直接拒绝(不进入格式判定)', () => {
+ expect(parseStrict('#f')).toBeNull();
+ expect(parseStrict('abc')).toBeNull();
+ });
+});
+
+describe('ColorPage.parseColorByType(按格式)', () => {
+ it('hex 只接受不带 # 的 6 位', () => {
+ expect(parseColorByType('hex', 'ff0000')?.hex()).toBe('#ff0000');
+ expect(parseColorByType('hex', '#ff0000')).toBeNull();
+ expect(parseColorByType('hex', 'fff')).toBeNull();
+ });
+
+ it('rgb 同时支持逗号与空格分隔', () => {
+ expect(parseColorByType('rgb', '255, 0, 0')?.hex()).toBe('#ff0000');
+ expect(parseColorByType('rgb', '255 0 0')?.hex()).toBe('#ff0000');
+ });
+
+ it('rgb 不做上界校验 —— 999 被交给 chroma 钳制(现状, 未修改)', () => {
+ // 上游 plugin.json 的颜色正则会挡住这类输入, 这里是用户手输格式框的宽松路径
+ expect(parseColorByType('rgb', '999,0,0')).not.toBeNull();
+ });
+
+ it('cmyk 支持百分数与 0..1 两种写法', () => {
+ expect(parseColorByType('cmyk', '0, 100, 100, 0')?.hex()).toBe('#ff0000');
+ expect(parseColorByType('cmyk', '0%, 100%, 100%, 0%')?.hex()).toBe('#ff0000');
+ expect(parseColorByType('cmyk', '1, 1, 1, 1')?.hex()).toBe('#000000');
+ });
+
+ it('hsl / hsi / hsv 走 chroma 对应色彩空间, 结果互不相同', () => {
+ const hsl = parseColorByType('hsl', '120, 50%, 50%')!.hex();
+ const hsi = parseColorByType('hsi', '120, 50%, 50%')!.hex();
+ const hsv = parseColorByType('hsv', '120, 50%, 50%')!.hex();
+ expect(hsl).toBe('#40bf40');
+ expect(hsi).toBe('#40ff40');
+ expect(hsv).toBe('#408040');
+ expect(new Set([hsl, hsi, hsv]).size).toBe(3);
+ });
+
+ it('未知格式一律 null', () => {
+ expect(parseColorByType('nope', '123')).toBeNull();
+ });
+});
+
+describe('AIPalettePage.parseColorInput(宽松)', () => {
+ it.each(['#fff', '#ffffff', '#FF0000', 'ff0000', 'rgb(255, 0, 0)', 'hsl(120, 50%, 50%)', 'red'])(
+ '接受 %s',
+ (input) => {
+ expect(parseLoose(input)).not.toBeNull();
+ },
+ );
+
+ it.each(['not-a-color', '#ggg', '', '255, 0, 0'])('拒绝 %s', (input) => {
+ expect(parseLoose(input)).toBeNull();
+ });
+
+ it('null 输入返回 null', () => {
+ expect(parseLoose(null)).toBeNull();
+ });
+});
+
+describe('两套实现的已知分歧(记录现状, 不视为缺陷)', () => {
+ it('cmyk: 严格实现能解析, 宽松实现(纯 chroma)不能', () => {
+ // chroma 本身没有 cmyk 字符串语法
+ expect(parseStrict('cmyk(0, 100, 100, 0)')).toBeNull();
+ expect(parseLoose('cmyk(0, 100, 100, 0)')).toBeNull();
+ // 但严格实现"按格式"这条路径能处理 —— 页面上 cmyk 输入框直接走 parseColorByType
+ expect(parseColorByType('cmyk', '0, 100, 100, 0')).not.toBeNull();
+ });
+
+ it('#11223344: 两者**不一致** —— 严格实现丢弃前两位, chroma 当成 RGBA', () => {
+ // 这是真实的分歧点, 不是笔误:
+ // ColorPage 把 8 位当 "忽略 alpha 的 #RRGGBBAA" -> 取后 6 位 #223344
+ // chroma 把 8 位当 RGBA, 前两位是 alpha 通道 -> 显式 hex() 给 #11223345
+ // 8 位 hex 只可能从上游 plugin.json 的正则进来(输入框不产出), 影响面小;
+ // 若将来要统一, 应作为独立一轮讨论, 这里只记录现状。
+ expect(parseStrict('#11223344')!.hex()).toBe('#223344');
+ expect(parseLoose('#11223344')!.hex()).toBe('#11223345');
+ });
+
+ it('对非法输入两者都返回 null', () => {
+ for (const bad of ['not-a-color', '#ggg', 'zzz']) {
+ expect(parseStrict(bad)).toBeNull();
+ expect(parseLoose(bad)).toBeNull();
+ }
+ });
+});
diff --git a/plugins/color-helper/tests/color.test.ts b/plugins/color-helper/tests/color.test.ts
new file mode 100644
index 000000000..190af0aa5
--- /dev/null
+++ b/plugins/color-helper/tests/color.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect } from 'vitest';
+import chroma from 'chroma-js';
+import { isDarkColor, isLightColor, textColorFor, dimColor, getContrastColor } from '../utils/color';
+/** WCAG 对比度 */
+function cr(l1: number, l2: number): number {
+ const hi = Math.max(l1, l2);
+ const lo = Math.min(l1, l2);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+/**
+ * isDarkColor 的判据是"白字对比度 >= 黑字对比度", 解出来正是
+ * luminance <= sqrt(0.05 * 1.05) - 0.05 ≈ 0.17912878474779204
+ */
+const LUMINANCE_THRESHOLD = Math.sqrt(0.05 * 1.05) - 0.05;
+
+describe('isDarkColor / isLightColor 严格互补', () => {
+ it('已知值的判定结果', () => {
+ // 期望值来自"白字/黑字哪个对比度更高"
+ expect(isDarkColor('#000000')).toBe(true);
+ expect(isDarkColor('#FFFFFF')).toBe(false);
+ expect(isDarkColor('#336699')).toBe(true);
+ expect(isDarkColor('#808080')).toBe(false);
+ expect(isDarkColor('#E91E63')).toBe(false);
+ expect(isDarkColor('#C4C4C4')).toBe(false);
+ });
+
+ it('对所有取样颜色严格互补(同一判据取反)', () => {
+ for (let r = 0; r < 256; r += 17) {
+ for (let g = 0; g < 256; g += 17) {
+ for (let b = 0; b < 256; b += 17) {
+ const hex = chroma.rgb(r, g, b).hex();
+ expect(isLightColor(hex)).toBe(!isDarkColor(hex));
+ }
+ }
+ }
+ });
+});
+
+describe('isDarkColor 与 WCAG 最优选择一致', () => {
+ it('不存在与 WCAG 最优选择不一致的取样颜色', () => {
+ let mismatches = 0;
+ for (let r = 0; r < 256; r += 17) {
+ for (let g = 0; g < 256; g += 17) {
+ for (let b = 0; b < 256; b += 17) {
+ const hex = chroma.rgb(r, g, b).hex();
+ const l = chroma(hex).luminance();
+ const wcagDark = cr(l, 1) >= cr(l, 0);
+ if (isDarkColor(hex) !== wcagDark) mismatches++;
+ }
+ }
+ }
+ expect(mismatches).toBe(0);
+ });
+
+ it('等价于 luminance < 0.17912878474779204', () => {
+ let mismatches = 0;
+ for (let r = 0; r < 256; r += 17) {
+ for (let g = 0; g < 256; g += 17) {
+ for (let b = 0; b < 256; b += 17) {
+ const hex = chroma.rgb(r, g, b).hex();
+ if (isDarkColor(hex) !== (chroma(hex).luminance() < LUMINANCE_THRESHOLD)) mismatches++;
+ }
+ }
+ }
+ expect(mismatches).toBe(0);
+ });
+
+ it('灰阶上的翻转点落在 #757575 与 #767676 之间', () => {
+ expect(isDarkColor('#757575')).toBe(true);
+ expect(isDarkColor('#767676')).toBe(false);
+ expect(chroma('#757575').luminance()).toBeLessThan(LUMINANCE_THRESHOLD);
+ expect(chroma('#767676').luminance()).toBeGreaterThan(LUMINANCE_THRESHOLD);
+ });
+});
+
+describe('文字配色派生函数与判据一致', () => {
+ it('textColorFor: 深底给白字, 浅底给黑字', () => {
+ expect(textColorFor('#000000')).toBe('#fff');
+ expect(textColorFor('#FFFFFF')).toBe('#000');
+ expect(textColorFor('#336699')).toBe('#fff');
+ });
+
+ it('getContrastColor 与 textColorFor 同一判据(字面量不同: 3位 vs 6位)', () => {
+ // 两者返回的字面量不同 —— textColorFor 是 '#fff' / '#000',
+ // getContrastColor 是 '#FFFFFF' / '#000000'。这里比较的是"选黑还是选白",
+ // 不是字符串本身
+ for (const hex of ['#000000', '#FFFFFF', '#336699', '#808080', '#E91E63', '#C4C4C4']) {
+ expect(chroma(getContrastColor(hex)).hex()).toBe(chroma(textColorFor(hex)).hex());
+ }
+ });
+
+ it('dimColor: 深底用白色半透明, 浅底用黑色半透明', () => {
+ expect(dimColor('#000000', 0.5)).toBe(chroma('#fff').alpha(0.5).hex());
+ expect(dimColor('#FFFFFF', 0.5)).toBe(chroma('#000').alpha(0.5).hex());
+ expect(dimColor('#000000', 0.5).slice(0, 7)).toBe('#ffffff');
+ expect(dimColor('#FFFFFF', 0.5).slice(0, 7)).toBe('#000000');
+ });
+});
diff --git a/plugins/color-helper/tests/data-integrity.test.ts b/plugins/color-helper/tests/data-integrity.test.ts
new file mode 100644
index 000000000..1a579f765
--- /dev/null
+++ b/plugins/color-helper/tests/data-integrity.test.ts
@@ -0,0 +1,176 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+/**
+ * data/ 下 10 个 JSON 的结构与色值合法性
+ *
+ * 数据是业务资产, 回归成本低 —— 记录数写成基线断言, 改动时会被提醒。
+ * 已知的**故意保留项**在下面标了"预期如此", 不是失败。
+ */
+
+function load
(name: string): T {
+ return JSON.parse(readFileSync(resolve(__dirname, `../data/${name}.json`), 'utf-8')) as T;
+}
+
+const HEX6 = /^#[0-9a-fA-F]{6}$/;
+
+type GradientData = string[][];
+interface UIItem { title: string; colors: Array }
+interface ChinaGroup { title: string; colors: Array<{ name: string; color: string }> }
+interface JapanItem { name: string; jname: string; color: string }
+
+const GRADIENTS = ['gradients-eT', 'gradients-nT', 'gradients-tT'] as const;
+const UIS = ['ui-ant-design', 'ui-flat-ui', 'ui-fluent', 'ui-material-design', 'ui-open-color'] as const;
+const ALL = [...GRADIENTS, ...UIS, 'traditional-china', 'traditional-japan'];
+
+describe('全部 JSON 都能解析且顶层是数组', () => {
+ it.each(ALL)('%s', (name) => {
+ expect(Array.isArray(load(name))).toBe(true);
+ });
+});
+
+describe('记录数基线(数据变更时应被提醒)', () => {
+ it.each([
+ ['gradients-eT', 10],
+ ['gradients-nT', 382],
+ ['gradients-tT', 60],
+ ['traditional-china', 24],
+ ['traditional-japan', 465],
+ ['ui-ant-design', 13],
+ ['ui-flat-ui', 14],
+ ['ui-fluent', 4],
+ ['ui-material-design', 19],
+ ['ui-open-color', 13],
+ ])('%s = %i 条', (name, count) => {
+ expect(load(name as string)).toHaveLength(count as number);
+ });
+
+ it('traditional-china 共 24 组 / 384 色', () => {
+ const groups = load('traditional-china');
+ expect(groups).toHaveLength(24);
+ expect(groups.reduce((s, g) => s + g.colors.length, 0)).toBe(384);
+ // 每组 16 色(24 节气 × 16 = 384)
+ expect(new Set(groups.map((g) => g.colors.length))).toEqual(new Set([16]));
+ });
+});
+
+describe('渐变数据', () => {
+ it.each(GRADIENTS)('%s 每个渐变 >= 2 个停靠点', (name) => {
+ for (const g of load(name)) {
+ expect(g.length).toBeGreaterThanOrEqual(2);
+ }
+ });
+
+ it.each(GRADIENTS)('%s 非空色值均为 3 或 6 位 hex', (name) => {
+ // 3 位 hex 只允许一处(见下一条断言把例外钉死)
+ for (const g of load(name)) {
+ for (const c of g) {
+ expect(c).toMatch(/^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/);
+ }
+ }
+ });
+
+ it('gradients-nT[27] 是唯一一处 3 位 hex("#FFF") —— 预期如此, 但记录在案', () => {
+ // 全项目其余色值都是 6 位, 只有这一条是 "#FFF"。
+ // chroma 能正常解析, 所以不影响渲染; 但若将来做数据清洗应把它规范化为 "#FFFFFF"。
+ // 这条断言的作用是把该例外钉住: 多出任何一处 3 位 hex 都会让这条测试变红。
+ const threeDigit: Array<{ index: number; value: string }> = [];
+ for (const name of GRADIENTS) {
+ load(name).forEach((g, i) => {
+ for (const c of g) if (/^#[0-9a-fA-F]{3}$/.test(c)) threeDigit.push({ index: i, value: c });
+ });
+ }
+ expect(threeDigit).toEqual([{ index: 27, value: '#FFF' }]);
+ });
+
+ it('gradients-nT 有 5 组重复渐变 —— 预期如此(内容决策, 不视为缺陷)', () => {
+ const seen = new Set();
+ let dup = 0;
+ for (const g of load('gradients-nT')) {
+ const key = g.join('|');
+ if (seen.has(key)) dup++;
+ seen.add(key);
+ }
+ expect(dup).toBe(5);
+ expect(seen.size).toBe(377);
+ });
+});
+
+describe('UI 色卡数据', () => {
+ it.each(UIS)('%s 的所有非空色值均为 6 位 hex', (name) => {
+ for (const group of load(name)) {
+ for (const entry of group.colors) {
+ const arr = Array.isArray(entry) ? entry : [entry];
+ for (const c of arr) {
+ if (c === '') continue; // 见下: material 的空串是预期如此
+ expect(c).toMatch(HEX6);
+ }
+ }
+ }
+ });
+
+ it('ui-material-design 固定 14 槽, 其中 12 个是空串 —— 预期如此', () => {
+ // Brown / Grey / Blue Grey 的 A100–A400 Material 官方无此强调色
+ const data = load('ui-material-design');
+ expect(data).toHaveLength(19);
+ expect(new Set(data.map((g) => g.colors.length))).toEqual(new Set([14]));
+ const empties = data.flatMap((g) => g.colors).filter((c) => c === '').length;
+ expect(empties).toBe(12);
+ });
+
+ it('ui-flat-ui 每槽是一组色阶(长度为 2 的数组), 不是单个色值', () => {
+ for (const group of load('ui-flat-ui')) {
+ expect(Array.isArray(group.colors[0])).toBe(true);
+ expect((group.colors[0] as string[]).length).toBe(2);
+ }
+ });
+
+ it('每个 UI 色卡都有 title', () => {
+ for (const name of UIS) {
+ for (const group of load(name)) {
+ expect(typeof group.title).toBe('string');
+ expect(group.title.length).toBeGreaterThan(0);
+ }
+ }
+ });
+});
+
+describe('传统色数据', () => {
+ it('465 条日本色都有 jname', () => {
+ const japan = load('traditional-japan');
+ expect(japan).toHaveLength(465);
+ expect(japan.filter((c) => !c.jname)).toHaveLength(0);
+ });
+
+ it('中国色无重名', () => {
+ const names = load('traditional-china').flatMap((g) => g.colors.map((c) => c.name));
+ expect(names).toHaveLength(384);
+ expect(new Set(names).size).toBe(384);
+ });
+
+ it('中国色无重色', () => {
+ const colors = load('traditional-china').flatMap((g) => g.colors.map((c) => c.color.toUpperCase()));
+ const dup = colors.length - new Set(colors).size;
+ expect(dup).toBe(0);
+ });
+
+ it.each(['traditional-china', 'traditional-japan'] as const)('%s 的色值均为 6 位 hex', (name) => {
+ const raw = load(name);
+ const colors = Array.isArray(raw) && typeof raw[0] === 'object' && 'colors' in (raw[0] as object)
+ ? (raw as ChinaGroup[]).flatMap((g) => g.colors.map((c) => c.color))
+ : (raw as JapanItem[]).map((c) => c.color);
+ expect(colors.length).toBeGreaterThan(0);
+ for (const c of colors) expect(c).toMatch(HEX6);
+ });
+
+ it('传统色名称不含占位符', () => {
+ const PLACEHOLDER = /^(?:test|测试|unnamed|n\/a|null|undefined|无|-|未知|placeholder)$/i;
+ const china = load('traditional-china').flatMap((g) => g.colors.map((c) => c.name));
+ const japan = load('traditional-japan').map((c) => c.name);
+ for (const n of [...china, ...japan]) {
+ expect(n).not.toMatch(PLACEHOLDER);
+ expect(n.trim().length).toBeGreaterThan(0);
+ }
+ });
+});
\ No newline at end of file
diff --git a/plugins/color-helper/tests/gradient-geometry.test.ts b/plugins/color-helper/tests/gradient-geometry.test.ts
new file mode 100644
index 000000000..4fe9db0fb
--- /dev/null
+++ b/plugins/color-helper/tests/gradient-geometry.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest';
+import { calculateGradientLine } from '../utils/gradient';
+
+/** 渐变线长度 = 起点到终点的距离 */
+function lineLength(w: number, h: number, angle: number): number {
+ const { tx, ty, bx, by } = calculateGradientLine(w, h, angle);
+ return Math.hypot(bx - tx, by - ty);
+}
+
+function round(v: number): number {
+ // +0 归一化: Math.sin(Math.PI) 会产出 -0, deepEqual 下 -0 !== 0
+ const r = Math.round(v * 100) / 100;
+ return r === 0 ? 0 : r;
+}
+
+/** 浮点端点比较: 逐项 toBeCloseTo, 避开 -0 与 1e-14 级误差 */
+function expectPoint(
+ actual: { tx: number; ty: number; bx: number; by: number },
+ start: number[],
+ end: number[],
+) {
+ expect(actual.tx).toBeCloseTo(start[0]!, 2);
+ expect(actual.ty).toBeCloseTo(start[1]!, 2);
+ expect(actual.bx).toBeCloseTo(end[0]!, 2);
+ expect(actual.by).toBeCloseTo(end[1]!, 2);
+}
+
+describe('calculateGradientLine 基线值', () => {
+ it.each([
+ [500, 500, 135, [0, 0], [500, 500], 707.11],
+ [500, 500, 0, [250, 500], [250, 0], 500],
+ [500, 500, 90, [0, 250], [500, 250], 500],
+ [500, 500, 180, [250, 0], [250, 500], 500],
+ [1000, 500, 135, [125, -125], [875, 625], 1060.66],
+ [1200, 900, 45, [75, 975], [1125, -75], 1484.92],
+ ])('%ix%i @ %i° → 起点 %o 终点 %o L=%f', (w, h, angle, start, end, len) => {
+ const line = calculateGradientLine(w as number, h as number, angle as number);
+ expectPoint(line, start as number[], end as number[]);
+ expect(round(lineLength(w as number, h as number, angle as number))).toBe(len);
+ });
+});
+
+describe('回归护栏: 不要退回半对角线', () => {
+ it('500×500 @135° 的端点不是半对角线那个错误值 (0,0)→(250,250)', () => {
+ // 半对角线错误实现的特征值: 长度 353.55(= 对角线的一半)
+ expect(round(lineLength(500, 500, 135))).not.toBe(353.55);
+ expect(lineLength(500, 500, 135)).not.toBeCloseTo(500 * Math.SQRT2 / 2, 2);
+ });
+
+ it('135° 的对角线长度应为 √2·w(而非半对角线)', () => {
+ expect(round(lineLength(500, 500, 135))).toBe(round(Math.SQRT2 * 500));
+ });
+});
+
+describe('calculateGradientLine 几何不变量', () => {
+ const sizes: Array<[number, number]> = [[500, 500], [1000, 500], [1200, 900], [1, 1], [333, 777]];
+
+ it('中点恒为画布中心', () => {
+ for (const [w, h] of sizes) {
+ for (const angle of [0, 45, 90, 135, 180, 225, 270, 315, 360]) {
+ const line = calculateGradientLine(w, h, angle);
+ expect((line.tx + line.bx) / 2).toBeCloseTo(w / 2, 6);
+ expect((line.ty + line.by) / 2).toBeCloseTo(h / 2, 6);
+ }
+ }
+ });
+
+ it('长度公式 = |w·sin a| + |h·cos a|', () => {
+ for (const [w, h] of sizes) {
+ for (const angle of [0, 30, 45, 90, 135, 180, 270, 315]) {
+ const rad = (angle * Math.PI) / 180;
+ const expected = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad));
+ expect(lineLength(w, h, angle)).toBeCloseTo(expected, 6);
+ }
+ }
+ });
+
+ it('0°/90°/180°/270° 落在画布边界的中点上', () => {
+ expectPoint(calculateGradientLine(400, 200, 0), [200, 200], [200, 0]);
+ expectPoint(calculateGradientLine(400, 200, 90), [0, 100], [400, 100]);
+ expectPoint(calculateGradientLine(400, 200, 180), [200, 0], [200, 200]);
+ expectPoint(calculateGradientLine(400, 200, 270), [400, 100], [0, 100]);
+ });
+});
diff --git a/plugins/color-helper/tests/platform.test.ts b/plugins/color-helper/tests/platform.test.ts
new file mode 100644
index 000000000..38b92b0b2
--- /dev/null
+++ b/plugins/color-helper/tests/platform.test.ts
@@ -0,0 +1,324 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { db, dbStorage, copyText } from '../utils/platform';
+
+/**
+ * utils/platform.ts 的浏览器降级路径
+ *
+ * 注意: 本文件在 happy-dom 下运行 —— 模块顶层就读了 window.platform,
+ * node 环境会直接抛 "window is not defined"(见 vitest.config.ts 的 environment)。
+ * 测试只需保证 window.platform 不存在, 即走降级分支。
+ */
+
+const PREFIX = 'color_helper_';
+
+beforeEach(() => {
+ localStorage.clear();
+ delete (window as any).platform;
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+});
+
+describe('dbStorage 降级(localStorage)', () => {
+ it('setItem / getItem 往返, 存的是 JSON', () => {
+ dbStorage.setItem('setting', true);
+ expect(localStorage.getItem(`${PREFIX}setting`)).toBe('true');
+ expect(dbStorage.getItem('setting')).toBe(true);
+ });
+
+ it('未设置的 key 返回 null', () => {
+ expect(dbStorage.getItem('nope')).toBeNull();
+ });
+
+ it('removeItem 移除键', () => {
+ dbStorage.setItem('k', 1);
+ dbStorage.removeItem('k');
+ expect(dbStorage.getItem('k')).toBeNull();
+ });
+
+ it('**损坏的 JSON 返回 null 而不是抛错**', () => {
+ localStorage.setItem(`${PREFIX}broken`, '{ not json');
+ expect(() => dbStorage.getItem('broken')).not.toThrow();
+ expect(dbStorage.getItem('broken')).toBeNull();
+ });
+});
+
+describe('db 降级(localStorage)', () => {
+ it('put 写入并按 _rev 递增', () => {
+ const r1 = db.put({ _id: 'c1', color: '#fff' });
+ expect(r1).toMatchObject({ ok: true, id: 'c1', rev: '1' });
+
+ const r2 = db.put({ _id: 'c1', color: '#000' });
+ expect(r2.rev).toBe('2');
+ expect(db.get('c1')).toMatchObject({ _id: 'c1', color: '#000', _rev: '2' });
+ });
+
+ it('get 对损坏 JSON 返回 null 而不是抛错', () => {
+ localStorage.setItem(`${PREFIX}db_c2`, '}}}');
+ expect(() => db.get('c2')).not.toThrow();
+ expect(db.get('c2')).toBeNull();
+ });
+
+ it('remove 删除文档', () => {
+ db.put({ _id: 'c3' });
+ expect(db.remove({ _id: 'c3' })).toEqual({ ok: true });
+ expect(db.get('c3')).toBeNull();
+ });
+
+ it('allDocs 返回全部文档', () => {
+ db.put({ _id: 'collect_1', color: '#111' });
+ db.put({ _id: 'collect_2', color: '#222' });
+ db.put({ _id: 'other_1', color: '#333' });
+ const all = db.allDocs();
+ expect(all.map((d) => d._id).sort()).toEqual(['collect_1', 'collect_2', 'other_1']);
+ });
+
+ it('allDocs(key) 按 _id 前缀过滤', () => {
+ db.put({ _id: 'collect_1' });
+ db.put({ _id: 'collect_2' });
+ db.put({ _id: 'other_1' });
+ expect(db.allDocs('collect').map((d) => d._id).sort()).toEqual(['collect_1', 'collect_2']);
+ });
+
+ it('**单个文档损坏只跳过该条, 其余照常返回**', () => {
+ db.put({ _id: 'collect_1', color: '#111' });
+ db.put({ _id: 'collect_2', color: '#222' });
+ // 插一条坏数据, 模拟一个文档写坏
+ localStorage.setItem(`${PREFIX}db_collect_bad`, '{ oops');
+
+ const docs = db.allDocs('collect');
+ expect(docs.map((d) => d._id).sort()).toEqual(['collect_1', 'collect_2']);
+ // 关键: 不是返回 []
+ expect(docs.length).toBe(2);
+ });
+
+ it('全部文档都损坏时返回空数组(不是抛错)', () => {
+ localStorage.setItem(`${PREFIX}db_a`, '{');
+ localStorage.setItem(`${PREFIX}db_b`, '}');
+ expect(() => db.allDocs()).not.toThrow();
+ expect(db.allDocs()).toEqual([]);
+ });
+
+ it('忽略前缀不匹配的 localStorage 键', () => {
+ localStorage.setItem('unrelated_key', JSON.stringify({ _id: 'x' }));
+ localStorage.setItem(`${PREFIX}other`, 'plain string');
+ expect(db.allDocs()).toEqual([]);
+ });
+});
+
+describe('db 走平台分支时的返回形状', () => {
+ /**
+ * 官方 db.put 返回 DbReturn { id, rev?, ok?, error?, name?, message? }
+ * (ztools.api.d.ts), 注意 ok 是**可选**、error 是**布尔**。
+ * 而降级分支返回的是 { ok: boolean, id, rev?, error?: string } ——
+ * 两个分支必须归一化成同一形状, 否则调用方的 `result.ok` / `result.error` 判据会分裂。
+ */
+ async function withPlatformDb(pdb: any) {
+ (window as any).platform = { db: pdb };
+ vi.resetModules();
+ return (await import('../utils/platform')).db;
+ }
+
+ afterEach(() => {
+ vi.resetModules();
+ });
+
+ it('put 归一化 DbReturn: ok/rev 透传, 无 error 时 error 为 undefined', async () => {
+ const put = vi.fn(() => ({ id: 'c1', rev: '3-abc', ok: true }));
+ const db = await withPlatformDb({ put });
+
+ const r = db.put({ _id: 'c1', color: '#fff' });
+
+ expect(put).toHaveBeenCalledWith({ _id: 'c1', color: '#fff' });
+ expect(r).toEqual({ ok: true, id: 'c1', rev: '3-abc', error: undefined });
+ // 调用方 CollectColorsPage 靠 result.rev 回写 _rev, 这个字段必须留着
+ expect(r.rev).toBe('3-abc');
+ });
+
+ it('put 遇到 error:true 时补出可读的 error 文案(优先 message)', async () => {
+ const db = await withPlatformDb({
+ put: () => ({ id: 'c1', error: true, name: 'conflict', message: 'Document update conflict' }),
+ });
+ const r = db.put({ _id: 'c1' });
+
+ expect(r.ok).toBe(false);
+ expect(r.error).toBe('Document update conflict');
+ expect(r.rev).toBeUndefined();
+ });
+
+ it('put 的 error 没有 message/name 时也要给出非空文案', async () => {
+ const db = await withPlatformDb({ put: () => ({ id: 'c1', error: true }) });
+ const r = db.put({ _id: 'c1' });
+
+ expect(r.ok).toBe(false);
+ expect(typeof r.error).toBe('string');
+ expect(r.error).toBeTruthy();
+ });
+
+ it('remove 走平台分支并归一化', async () => {
+ const remove = vi.fn(() => ({ id: 'c1', ok: true }));
+ const db = await withPlatformDb({ remove });
+
+ expect(db.remove({ _id: 'c1' })).toEqual({ ok: true, error: undefined });
+ expect(remove).toHaveBeenCalledWith({ _id: 'c1' });
+ });
+
+ it('**两个分支的返回形状一致** —— 调用方不需要区分自己跑在哪边', async () => {
+ // 平台分支
+ const dbPlatform = await withPlatformDb({ put: () => ({ id: 'x', rev: '1', ok: true }) });
+ const fromPlatform = dbPlatform.put({ _id: 'x' });
+
+ // 降级分支
+ delete (window as any).platform;
+ vi.resetModules();
+ const dbLocal = (await import('../utils/platform')).db;
+ const fromLocal = dbLocal.put({ _id: 'x' });
+
+ expect(Object.keys(fromPlatform).sort()).toEqual(Object.keys(fromLocal).sort());
+ expect(typeof fromPlatform.ok).toBe(typeof fromLocal.ok);
+ expect(typeof fromPlatform.id).toBe(typeof fromLocal.id);
+ });
+});
+
+describe('aiChat 不能凭空塞模型 ID', () => {
+ /**
+ * 宿主的 resolveModel 只在 model 留空时才走兜底「首个已开启供应商的首个模型」;
+ * 传一个它解析不出的 ID 会直接返回 null, 调用点报「未找到 AI 模型配置」,
+ * 用户自己配好的模型也用不了 —— 所以这里不能有任何硬编码的默认模型。
+ */
+ async function withPlatformAi(ai: any) {
+ (window as any).platform = { ai };
+ vi.resetModules();
+ return await import('../utils/platform');
+ }
+
+ /** 取自录一次 ai() 收到的 option */
+ const lastOption = (ai: any) => ai.mock.calls[0][0] as { model?: string; messages: unknown[] };
+
+ afterEach(() => {
+ vi.resetModules();
+ });
+
+ it('**不传 model 时 option.model 必须是 undefined**, 不能有硬编码默认值', async () => {
+ const ai = vi.fn(async () => ({ role: 'assistant', content: 'ok' }));
+ const p = await withPlatformAi(ai);
+
+ await p.aiChat([{ role: 'user', content: 'hi' }]);
+
+ expect(ai).toHaveBeenCalledTimes(1);
+ const option = lastOption(ai);
+ expect(option.model).toBeUndefined();
+ expect(Object.keys(option).sort()).toEqual(['messages', 'model']);
+ });
+
+ it('不传 model 时 option 里没有硬编码的供应商模型 ID', async () => {
+ const ai = vi.fn(async () => ({ role: 'assistant', content: 'ok' }));
+ const p = await withPlatformAi(ai);
+
+ await p.aiChat([{ role: 'user', content: 'hi' }]);
+
+ expect(JSON.stringify(lastOption(ai))).not.toMatch(/doubao|gpt-|qwen/i);
+ });
+
+ it('显式传入 model 时原样透传', async () => {
+ const ai = vi.fn(async () => ({ role: 'assistant', content: 'ok' }));
+ const p = await withPlatformAi(ai);
+
+ await p.aiChat([{ role: 'user', content: 'hi' }], 'some-provider:model-x');
+
+ expect(lastOption(ai).model).toBe('some-provider:model-x');
+ });
+
+ it('isAIAvailable 直接看 platform.ai, 不依赖其他字段', async () => {
+ expect((await withPlatformAi(undefined)).isAIAvailable()).toBe(false);
+ expect((await withPlatformAi(vi.fn())).isAIAvailable()).toBe(true);
+ });
+});
+
+describe('copyText 的降级与回退', () => {
+ it('平台存在时直接走平台 API, 不碰 navigator.clipboard', async () => {
+ // platform 是模块顶层读的(const platform = window.platform),
+ // 所以必须重置模块注册表后重新 import 才能让桩生效
+ const platformCopy = vi.fn();
+ (window as any).platform = { copyText: platformCopy };
+ const clipboardSpy = vi.fn(() => Promise.resolve());
+ vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } });
+
+ vi.resetModules();
+ const fresh = await import('../utils/platform');
+ fresh.copyText('#abcdef');
+
+ expect(platformCopy).toHaveBeenCalledWith('#abcdef');
+ expect(clipboardSpy).not.toHaveBeenCalled();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ });
+
+ it('clipboard.writeText 成功时不走 execCommand', async () => {
+ const writeText = vi.fn(() => Promise.resolve());
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
+ const execSpy = vi.fn(() => true);
+ document.execCommand = execSpy;
+
+ copyText('#123456');
+ await Promise.resolve();
+
+ expect(writeText).toHaveBeenCalledWith('#123456');
+ expect(execSpy).not.toHaveBeenCalled();
+ vi.unstubAllGlobals();
+ });
+
+ it('clipboard 被拒(Promise reject)时回退到 execCommand', async () => {
+ // try/catch 必须能接住 Promise 的异步 reject:
+ // 同步 try/catch 接不住的话, 权限被拒时会静默丢弃, 走不到回退分支。
+ const writeText = vi.fn(() => Promise.reject(new Error('NotAllowedError')));
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
+ const execSpy = vi.fn(() => true);
+ document.execCommand = execSpy;
+
+ copyText('#aabbcc');
+ // 等 writeText 的 reject 被 catch 处理
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(writeText).toHaveBeenCalledWith('#aabbcc');
+ expect(execSpy).toHaveBeenCalledWith('copy');
+ vi.unstubAllGlobals();
+ });
+
+ it('navigator.clipboard 不存在(非安全上下文)时直接走 execCommand', () => {
+ vi.stubGlobal('navigator', {});
+ const execSpy = vi.fn(() => true);
+ document.execCommand = execSpy;
+
+ copyText('#000000');
+
+ expect(execSpy).toHaveBeenCalledWith('copy');
+ vi.unstubAllGlobals();
+ });
+
+ it('回退实现不留残留 textarea 在 DOM 里', () => {
+ vi.stubGlobal('navigator', {});
+ document.execCommand = vi.fn(() => true);
+
+ copyText('#eeeeee');
+
+ expect(document.querySelectorAll('textarea')).toHaveLength(0);
+ vi.unstubAllGlobals();
+ });
+
+ it('回退实现把文本写进了 textarea', () => {
+ vi.stubGlobal('navigator', {});
+ let captured = '';
+ document.execCommand = vi.fn(() => {
+ captured = (document.querySelector('textarea') as HTMLTextAreaElement)?.value ?? '';
+ return true;
+ });
+
+ copyText('#fedcba');
+
+ expect(captured).toBe('#fedcba');
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/plugins/color-helper/tests/plugin-json.test.ts b/plugins/color-helper/tests/plugin-json.test.ts
new file mode 100644
index 000000000..c907e9586
--- /dev/null
+++ b/plugins/color-helper/tests/plugin-json.test.ts
@@ -0,0 +1,204 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+/**
+ * public/plugin.json 的颜色匹配正则 —— 最需要上锁的地方
+ *
+ * 两条硬约束:
+ * 1. 裸 hex 分支必须要求"同时含数字和 a-f 字母", 只用 `^[a-f0-9]{6}$` 会把
+ * decade / facade / accede / beaded 这类英文单词和 123456 / 000000 / ffffff
+ * 一起误判成颜色。故加两个 lookahead。
+ * 2. 大小写不敏感必须写在 pattern 本身([a-fA-F0-9] / [rR][gG][bB] ...),
+ * 不能靠结尾的 /i —— 宿主在主输入框边打边搜时会丢光 flags,
+ * 依赖 flag 的话 `#FFF` / `FF0000` 在主输入框里根本不匹配。
+ */
+
+interface PluginCmd { type?: string; match?: string; minLength?: number; maxLength?: number; label?: string }
+interface PluginFeature { code: string; explain?: string; cmds: Array }
+
+const plugin = JSON.parse(
+ readFileSync(resolve(__dirname, '../public/plugin.json'), 'utf-8'),
+) as { features: PluginFeature[] };
+
+/** 取某个功能里 type=regex 的那条命令; 该功能没有 regex 命令时返回 undefined */
+function regexCmd(featureCode: string): PluginCmd | undefined {
+ const feature = plugin.features.find((f) => f.code === featureCode);
+ if (!feature) throw new Error(`plugin.json 里找不到 feature: ${featureCode}`);
+ return feature.cmds.find((c): c is PluginCmd => typeof c === 'object' && c.type === 'regex');
+}
+
+/** 该功能必须有 regex 命令, 取不到就抛 —— 给"颜色"这条必填路径用 */
+function requiredRegexCmd(featureCode: string): PluginCmd {
+ const cmd = regexCmd(featureCode);
+ if (!cmd) throw new Error(`feature ${featureCode} 里没有 regex 命令`);
+ return cmd;
+}
+
+/** JSON 里存的是 "/^...$/i" 字符串, 剥掉首尾定界符还原成 RegExp */
+function toRegExp(match: string): RegExp {
+ const m = match.match(/^\/(.*)\/([a-z]*)$/s);
+ if (!m) throw new Error(`match 不是 /.../flags 形式: ${match}`);
+ return new RegExp(m[1]!, m[2]!);
+}
+
+/**
+ * 模拟宿主"边打边搜"分支: parseMatchPattern 的 preserveFlags 为 false, flags 被整个丢掉。
+ */
+function toRegExpWithoutFlags(match: string): RegExp {
+ const m = match.match(/^\/(.*)\/([a-z]*)$/s);
+ if (!m) throw new Error(`match 不是 /.../flags 形式: ${match}`);
+ return new RegExp(m[1]!);
+}
+
+const colorRe = toRegExp(requiredRegexCmd('color').match!);
+/** flags 被丢掉后的版本 —— 主输入框实际用到的就是它 */
+const colorReNoFlags = toRegExpWithoutFlags(requiredRegexCmd('color').match!);
+
+/** 全部应当匹配的用例, 供两组正则共用 */
+const SHOULD_MATCH = [
+ '#fff',
+ '#FFF',
+ '#ffffff',
+ '#FFFFFF',
+ '#11223344',
+ '#11223344'.toUpperCase(),
+ 'ff0000',
+ 'FF0000',
+ 'a1b2c3',
+ 'A1B2C3',
+ '255, 0, 0',
+ '255 0 0',
+ 'rgb(1,2,3)',
+ 'RGB(1,2,3)',
+ 'rgb(1, 2, 3)',
+ 'rgba(1,2,3,0.5)',
+ 'RGBA(1,2,3,0.5)',
+ 'hsl(120, 50%, 50%)',
+ 'HSL(120, 50%, 50%)',
+ 'hsla(120, 50%, 50%, 0.5)',
+ 'hsv(120, 50%, 50%)',
+ 'hsi(120, 50%, 50%)',
+ 'hsl(120deg, 50%, 50%)',
+ '#fff;',
+];
+
+const SHOULD_REJECT = [
+ // 裸 hex 分支最典型的假阳性: 英文单词与纯数字串
+ 'decade',
+ 'facade',
+ 'accede',
+ 'beaded',
+ '123456',
+ '000000',
+ 'ffffff',
+ 'DECADE',
+ 'FACADE',
+ // 其它非法输入
+ '#gggggg',
+ '#GGGGGG',
+ '',
+ 'rgb(1,2,3',
+ 'rgb(1,2,3)extra',
+ 'rgb(255, 0, 0,)',
+ 'hsl(120, 50%, 50%%',
+ 'rgb(999,0,0)',
+ '1,2',
+ '1,2,3,4',
+ '999,999,999',
+ '#ffff',
+ 'the cafe',
+ 'the CAFE',
+ 'red',
+ '##fff',
+ '#ff',
+];
+
+describe('颜色正则的硬约束', () => {
+ it('**不使用 maxLength** —— 这不是 RegexCmd 的字段, 写了也不生效', () => {
+ // 官方 RegexCmd 只有 { type, minLength, match, label },
+ // maxLength 只存在于 OverCmd / FilesCmd。
+ expect(requiredRegexCmd('color').maxLength).toBeUndefined();
+ });
+
+ it('**不以 /i 收尾** —— 大小写不敏感必须写在 pattern 里', () => {
+ // 一旦有人把 /i 加回来当"保险", 这条会红, 提醒他 pattern 已经自足了
+ const m = requiredRegexCmd('color').match!;
+ expect(m.match(/^\/(.*)\/([a-z]*)$/s)![2]).toBe('');
+ });
+
+ it('**pattern 里不含裸的小写关键字** —— rgb/hsl/deg 等都必须写成 [rR][gG][bB]', () => {
+ const body = requiredRegexCmd('color').match!.replace(/^\/|\/$/g, '');
+ // 先摘掉括号字符类里的内容, 再看剩下的部分有没有裸露的 rgb/hsl/deg
+ const outsideClasses = body.replace(/\[[^\]]*\]/g, '□');
+ expect(outsideClasses).not.toMatch(/rgb|hsl|hsv|hsi|deg/);
+ });
+
+ it('**AI 配色不挂颜色正则** —— 与「颜色」撞同一个入口只会多出一条冗余候选', () => {
+ // 粘一个色值时彩色候选由「颜色」一条负责; AI 配色是重交互页面, 不该出现在
+ // 这种即时匹配里, 入口只留 "AI 配色" 文本命令。
+ expect(regexCmd('ai')).toBeUndefined();
+ expect(plugin.features.find((f) => f.code === 'ai')!.cmds).toEqual(['AI 配色']);
+ });
+});
+
+describe.each([
+ ['color(带 flags)', colorRe],
+ ['**color(flags 被宿主丢掉, 即边打边搜)**', colorReNoFlags],
+])('%s 功能正则', (_name, re) => {
+ it.each(SHOULD_MATCH)('应当匹配: %s', (input) => {
+ expect(re.test(input)).toBe(true);
+ });
+
+ it.each(SHOULD_REJECT)('应当拒绝: %s', (input) => {
+ expect(re.test(input)).toBe(false);
+ });
+});
+
+describe('边打边搜与粘贴/快捷键两条路径结果必须一致', () => {
+ // 边打边搜(preserveFlags: false 丢光 flags)与粘贴/快捷键两条路径, 判定必须一致
+ it.each([...SHOULD_MATCH, ...SHOULD_REJECT])(
+ 'preserveFlags 两分支对 %s 的判定相同',
+ (input) => {
+ expect(colorReNoFlags.test(input)).toBe(colorRe.test(input));
+ },
+ );
+
+ it('丢 flag 时这几个样本必须仍然匹配', () => {
+ for (const s of ['#FFF', '#FFFFFF', 'FF0000', 'A1B2C3', 'RGB(1,2,3)', 'HSL(120, 50%, 50%)']) {
+ expect(colorReNoFlags.test(s)).toBe(true);
+ }
+ });
+});
+
+describe('正则标志', () => {
+ it('无 g/y 标志, lastIndex 不会导致交替调用结果漂移', () => {
+ expect(colorRe.global).toBe(false);
+ expect(colorRe.sticky).toBe(false);
+ expect(colorReNoFlags.global).toBe(false);
+ expect(colorReNoFlags.sticky).toBe(false);
+ });
+});
+
+describe('侧边栏顺序与 plugin.json 对齐', () => {
+ /**
+ * 同一个功能在插件里只该有一个排名 —— App.tsx 的 navItems 与 plugin.json 的
+ * features 各排一套的话, 改了一处忘了另一处就开始打架, 而且没人看得出来。
+ * pickercolor 是纯动作(执行后落在颜色页), 不在侧边栏里, 故排除。
+ */
+ it('navItems 的 key 序列是 features 剔除 pickercolor 后的子序列', async () => {
+ const { navItems } = await import('../App');
+ const menuCodes = plugin.features
+ .map((f) => f.code)
+ .filter((code) => code !== 'pickercolor');
+
+ expect(navItems.map((item) => item.key)).toEqual(menuCodes);
+ });
+
+ it('**侧边栏每项在 plugin.json 里都有同名页签** —— 否则点进去的 code 宿主不认', async () => {
+ const { navItems } = await import('../App');
+ const codes = new Set(plugin.features.map((f) => f.code));
+
+ navItems.forEach((item) => expect(codes.has(item.key)).toBe(true));
+ });
+});
diff --git a/plugins/color-helper/tests/quantize.test.ts b/plugins/color-helper/tests/quantize.test.ts
new file mode 100644
index 000000000..7e201ae65
--- /dev/null
+++ b/plugins/color-helper/tests/quantize.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect } from 'vitest';
+import { quantize } from '../utils/quantize';
+
+/** 生成参考像素集: r∈[0,256,4) × g∈[0,256,8) × b∈[0,256,16) = 32768 个 */
+function referencePixels(): number[][] {
+ const px: number[][] = [];
+ for (let r = 0; r < 256; r += 4)
+ for (let g = 0; g < 256; g += 8)
+ for (let b = 0; b < 256; b += 16) px.push([r, g, b]);
+ return px;
+}
+
+describe('quantize 参数校验', () => {
+ it('空像素集返回 null', () => {
+ expect(quantize([], 10)).toBeNull();
+ });
+
+ it('maxColors < 2 返回 null(但 1 是合法整数, 不抛错)', () => {
+ expect(quantize([[1, 2, 3]], 1)).toBeNull();
+ });
+
+ it('maxColors 非整数抛 Error', () => {
+ expect(() => quantize([[1, 2, 3]], 2.5)).toThrow();
+ });
+
+ it('maxColors 超出 1..256 抛 Error', () => {
+ expect(() => quantize([[1, 2, 3]], 0)).toThrow();
+ expect(() => quantize([[1, 2, 3]], 257)).toThrow();
+ });
+});
+
+describe('quantize 两个分支', () => {
+ it('去重后 <= maxColors 走 SimpleCMap: map() 原样返回像素', () => {
+ const cmap = quantize([[1, 2, 3], [4, 5, 6], [7, 8, 9]], 10)!;
+ expect(cmap.map([1, 2, 3])).toEqual([1, 2, 3]);
+ });
+
+ it('SimpleCMap 的 palette 就是去重后的原像素', () => {
+ const cmap = quantize([[1, 2, 3], [1, 2, 3], [4, 5, 6]], 10)!;
+ expect(cmap.palette()).toEqual([[1, 2, 3], [4, 5, 6]]);
+ });
+
+ it('去重后 > maxColors 走 CMap 分支, 返回 maxColors 个代表色', () => {
+ const cmap = quantize(referencePixels(), 10)!;
+ expect(cmap.palette()).toHaveLength(10);
+ });
+});
+
+describe('回归重点: palette() / map() 的返回形状', () => {
+ it('palette() 每项是长度 3 的数组, 元素在 0..255', () => {
+ const palette = quantize(referencePixels(), 10)!.palette();
+ expect(palette.length).toBeGreaterThan(0);
+ for (const c of palette) {
+ expect(Array.isArray(c)).toBe(true);
+ expect(c).toHaveLength(3);
+ for (const ch of c) {
+ expect(Number.isInteger(ch)).toBe(true);
+ expect(ch).toBeGreaterThanOrEqual(0);
+ expect(ch).toBeLessThanOrEqual(255);
+ }
+ }
+ });
+
+ it('map() 返回 number[] 而不是 number', () => {
+ const mapped = quantize(referencePixels(), 10)!.map([12, 34, 56]);
+ expect(Array.isArray(mapped)).toBe(true);
+ expect(mapped).toHaveLength(3);
+ });
+});
+
+describe('回归基线: 固定输入产出固定结果', () => {
+ it('32768 像素量化为 10 色', () => {
+ const cmap = quantize(referencePixels(), 10)!;
+ expect(cmap.palette()).toHaveLength(10);
+ });
+
+ it('重复调用结果稳定(优先队列无随机性)', () => {
+ const a = quantize(referencePixels(), 10)!.palette();
+ const b = quantize(referencePixels(), 10)!.palette();
+ expect(a).toEqual(b);
+ });
+});
diff --git a/plugins/color-helper/tests/traditional-color.test.ts b/plugins/color-helper/tests/traditional-color.test.ts
new file mode 100644
index 000000000..9c75a84f8
--- /dev/null
+++ b/plugins/color-helper/tests/traditional-color.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { getColorAttr } from '../pages/TraditionalColorsPage';
+import { classifyColor } from '../pages/GradientsPage';
+import { isDarkColor } from '../utils/color';
+
+/**
+ * 传统色的 tag/dark 判定
+ * tag 分段: l<0.2 black / l>0.85 white / s<0.2 gray / 其余按色相分段
+ */
+
+describe('getColorAttr: tag 分类', () => {
+ it('明度两端', () => {
+ expect(getColorAttr('#000000').tag).toBe('black');
+ expect(getColorAttr('#ffffff').tag).toBe('white');
+ });
+
+ it('低饱和走 gray', () => {
+ expect(getColorAttr('#808080').tag).toBe('gray');
+ expect(getColorAttr('#C4C4C4').tag).toBe('gray');
+ });
+
+ it('色相分段', () => {
+ expect(getColorAttr('#E91E63').tag).toBe('red');
+ expect(getColorAttr('#336699').tag).toBe('blue');
+ expect(getColorAttr('#2E8B57').tag).toBe('green');
+ expect(getColorAttr('#FFD700').tag).toBe('yellow');
+ });
+});
+
+describe('getColorAttr: dark 与统一判据一致', () => {
+ it('等于 isDarkColor(与全局统一判据一致)', () => {
+ const samples = [
+ '#000000', '#ffffff', '#808080', '#E91E63', '#336699', '#C4C4C4',
+ '#FFF799', '#B6A014', '#D5EBE1', '#80A492', '#FFF', '#123456',
+ ];
+ for (const c of samples) {
+ expect(getColorAttr(c).dark).toBe(isDarkColor(c));
+ }
+ });
+});
+
+describe('getColorAttr: 边界值', () => {
+ it('灰阶下落为 0 而不是 NaN 分支', () => {
+ // 纯灰的 chroma.hsl()[0] 是 NaN, 实现里回落为 0 -> 落在 red 段
+ // (只因 s < 0.2 更早命中, 实际 tag 是 gray)
+ expect(getColorAttr('#808080').tag).toBe('gray');
+ expect(getColorAttr('#ffffff').tag).toBe('white');
+ expect(getColorAttr('#000000').tag).toBe('black');
+ });
+
+ it('l 恰好 0.2 / s 恰好 0.2 的邻域不发散', () => {
+ // hsl(120, 50%, 20%) -> l 正好 0.2, 不满足 l<0.2, 落到色相分段
+ expect(getColorAttr('#1a4d1a').tag).not.toBe('black');
+ // hsl(120, 50%, 19%) -> l < 0.2
+ expect(getColorAttr('#184618').tag).toBe('black');
+ });
+
+ it('色相恰好落在分段边界上时归入后一段', () => {
+ // h=70 不满足 <70, 归 green
+ expect(getColorAttr('hsl(70, 100%, 50%)').tag).toBe('green');
+ // h=69.9 归 yellow
+ expect(getColorAttr('hsl(69.9, 100%, 50%)').tag).toBe('yellow');
+ });
+});
+
+describe('classifyColor(渐变页) 与 getColorAttr(传统色页) 同源', () => {
+ // 两个文件各有一份色相分段表, 词表必须一致 —— 否则同一颜色在
+ // 渐变页筛选项与传统色页筛选项会归到不同类
+ it('tag 词表一致', () => {
+ const hues = [0, 20, 30, 60, 100, 170, 200, 300, 340, 359];
+ for (const h of hues) {
+ const color = `hsl(${h}, 80%, 45%)`;
+ expect(classifyColor(color)).toBe(getColorAttr(color).tag);
+ }
+ });
+
+ it('通用取样下两者结果相同', () => {
+ for (let r = 0; r < 256; r += 51) {
+ for (let g = 0; g < 256; g += 51) {
+ for (let b = 0; b < 256; b += 51) {
+ const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
+ expect(classifyColor(hex)).toBe(getColorAttr(hex).tag);
+ }
+ }
+ }
+ });
+});
diff --git a/plugins/color-helper/tsconfig.json b/plugins/color-helper/tsconfig.json
index 3934b8f6d..efc3bc2bd 100644
--- a/plugins/color-helper/tsconfig.json
+++ b/plugins/color-helper/tsconfig.json
@@ -16,6 +16,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src"],
+ "include": ["App.tsx", "main.tsx", "vite-env.d.ts", "pages", "utils", "types", "data", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/plugins/color-helper/tsconfig.node.json b/plugins/color-helper/tsconfig.node.json
index 42872c59f..7bfd9ca99 100644
--- a/plugins/color-helper/tsconfig.node.json
+++ b/plugins/color-helper/tsconfig.node.json
@@ -1,10 +1,13 @@
{
"compilerOptions": {
"composite": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./node_modules/.tmp/tsconfig.node",
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "vitest.config.ts"]
}
diff --git a/plugins/color-helper/types/index.ts b/plugins/color-helper/types/index.ts
index 33188d500..5db381282 100644
--- a/plugins/color-helper/types/index.ts
+++ b/plugins/color-helper/types/index.ts
@@ -1,93 +1,140 @@
-// 颜色格式类型
-export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsv';
+// 颜色数据模型
+//
+// 与 data/*.json 的实际形状一一对应(已逐一核对, 见各注释)。
+// 注意: tsconfig 开启了 resolveJsonModule, JSON 推断为字面量类型,
+// 因此结构不符不会在编译期报错 —— 必须靠这里的接口约束住,
+// 各页面通过 `as unknown as Xxx[]` 显式声明形状后再消费。
-// 颜色对象接口
-export interface ColorObject {
- hex: string;
- rgb: { r: number; g: number; b: number };
- hsl: { h: number; s: number; l: number };
- hsv: { h: number; s: number; v: number };
-}
+/** 渐变色预设 —— data/gradients-*.json 的实际形状: 颜色数组的数组(无 name/angle/category) */
+export type GradientPreset = string[];
-// 收藏的颜色项
-export interface CollectedColor {
- id: string;
- name: string;
- color: string;
- createdAt: number;
+/** Material Design 色板 —— data/ui-material-design.json: 14 槽(10 主色 + A100~A400, 部分色系无强调色时为空串) */
+export interface MaterialColorCategory {
+ title: string;
+ colors: string[];
}
-// 渐变色配置
-export interface GradientConfig {
+/** 通用 UI 色板 —— data/ui-ant-design.json / ui-open-color.json / ui-fluent.json */
+export interface UIColorCategory {
+ title: string;
colors: string[];
- angle: number;
- type: 'linear' | 'radial';
+ /** 仅 ant-design 提供, 作者标注(如"平稳、中态"), 界面暂未展示 */
+ description?: string;
}
-// 渐变预设
-export interface GradientPreset {
- name: string;
- colors: string[];
- angle?: number;
- category: string;
+/** Flat UI 色板 —— data/ui-flat-ui.json: 每槽为一组色阶(6 个字符串), 不是单个色值 */
+export interface FlatUIColorCategory {
+ title: string;
+ colors: string[][];
}
-// UI色卡分类
-export interface UIColorCategory {
- name: string;
- colors: string[];
+/** 中国传统色分组 —— data/traditional-china.json: { title, colors: [{name,color}] } */
+export interface ChinaColorCategory {
+ title: string;
+ colors: Array<{ name: string; color: string }>;
}
-// 传统色分类
-export interface TraditionalColorCategory {
+/** 日本传统色条目 —— data/traditional-japan.json: 扁平列表, 额外带日语假名 */
+export interface JapanColorItem {
name: string;
- title: string;
- colors: Array<{
- name: string;
- color: string;
- pinyin?: string;
- }>;
+ color: string;
+ jname: string;
}
+/** 官方 getPath 允许的路径名 */
+export type PlatformPathName =
+ | 'home' | 'appData' | 'userData' | 'cache' | 'temp' | 'exe' | 'module'
+ | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos'
+ | 'logs' | 'pepperFlashSystemPlugin';
+
// 平台 API 类型声明
+//
+// 手写声明, 权威依据是官方 ztools.api.d.ts (@ztools-center/ztools-api-types)。
+// 只声明本项目实际用到的成员, 未用到的部分请直接对照官方 d.ts。
+//
+// 改动前请先核对官方 d.ts 与宿主实现, 不要凭印象改签名。
declare global {
+ /** 官方 DbDoc: 文档必须带 _id, _rev 由平台维护 */
+ interface PlatformDbDoc { _id: string; _rev?: string; [k: string]: any }
+
+ /** 官方 DbReturn —— db.put / db.remove 的同步返回 */
+ interface PlatformDbReturn {
+ id: string;
+ rev?: string;
+ ok?: boolean;
+ error?: boolean;
+ name?: string;
+ message?: string;
+ }
+
+ /** onPluginEnter 回调的 action */
+ interface PlatformEnterAction {
+ code: string;
+ type: string;
+ payload: any;
+ option?: any;
+ }
+
interface Window {
platform?: {
- onPluginEnter: (callback: (action: { code: string; type: string; payload: any }) => void) => void;
- onPluginOut: (callback: () => void) => void;
+ onPluginEnter: (callback: (action: PlatformEnterAction) => void) => void;
+ /** 回调参数是 processExit(进程是否退出) */
+ onPluginOut: (callback: (processExit: boolean) => void) => void;
dbStorage: {
getItem: (key: string) => any;
setItem: (key: string, value: any) => void;
removeItem: (key: string) => void;
};
- copyText: (text: string) => void;
- copyImage: (base64: string) => void;
- showNotification: (title: string, body: string) => void;
- setSubInput: (callback: (data: { text: string }) => void, placeholder: string, isFocus: boolean) => void;
- removeSubInput: () => void;
- hideMainWindow: () => void;
- outPlugin: () => void;
+ copyText: (text: string) => boolean;
+ /** img 可以是 base64 dataURL、Uint8Array 或图片路径 */
+ copyImage: (img: string | Uint8Array) => boolean;
+ /** 第一参是通知正文, featureName 用于标识来源功能 */
+ showNotification: (body: string, featureName?: string) => void;
+ setSubInput: (
+ onChange: (input: { text: string }) => void,
+ placeholder?: string,
+ isFocus?: boolean,
+ ) => boolean;
+ removeSubInput: () => boolean;
+ hideMainWindow: (isRestorePreWindow?: boolean) => boolean;
+ showMainWindow: () => boolean;
+ outPlugin: (isKill?: boolean) => boolean;
+ /** 宿主侧为异步弹窗, 返回值可能为 undefined, 调用方需容错 */
showSaveDialog: (options: {
- title: string;
- defaultPath: string;
- buttonLabel: string;
- filters: Array<{ extensions: string[]; name: string }>;
+ title?: string;
+ defaultPath?: string;
+ buttonLabel?: string;
+ filters?: Array<{ extensions: string[]; name: string }>;
+ properties?: string[];
}) => string | undefined;
+ /** 宿主侧为异步弹窗, 返回值可能为 undefined, 调用方需容错 */
showOpenDialog: (options: {
title?: string;
+ defaultPath?: string;
+ buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
}) => string[] | undefined;
- shellShowItemInFolder: (path: string) => void;
- getPath: (name: string) => string;
- pickColor: () => string;
- screenColorPick: (callback: (result: { hex: string }) => void) => void;
- screenCapture: (callback: (data: string) => void) => void;
+ shellShowItemInFolder: (fullPath: string) => void;
+ getPath: (name: PlatformPathName) => string;
+ screenColorPick: (callback: (color: { hex: string; rgb: string }) => void) => void;
+ /** imgBase64 是 data URL; bounds 仅 Windows / Linux 有值, macOS 为 undefined */
+ screenCapture: (
+ callback: (imgBase64: string, bounds?: { x: number; y: number; width: number; height: number }) => void,
+ ) => void;
db: {
- put: (doc: any) => any;
- get: (id: string) => any;
- remove: (id: string) => any;
+ put: (doc: PlatformDbDoc) => PlatformDbReturn;
+ get: (id: string) => PlatformDbDoc | null;
+ remove: (doc: string | PlatformDbDoc) => PlatformDbReturn;
+ allDocs: (key?: string) => PlatformDbDoc[];
};
+ /** model 留空时由宿主选用用户已配置的默认模型 */
+ ai: (option: {
+ model?: string;
+ messages: Array<{ role: 'system' | 'user' | 'assistant'; content?: string }>;
+ }) => Promise<{ role: string; content?: string; reasoning_content?: string; abort?: () => void }>;
+ /** 列出用户已配置的模型, 其 id / value 可回传给 ai() 的 model */
+ allAiModels: () => Promise>;
};
services?: {
saveColorCard: (buffer: ArrayBuffer) => Promise;
diff --git a/plugins/color-helper/utils/collect.ts b/plugins/color-helper/utils/collect.ts
new file mode 100644
index 000000000..cc296cb3e
--- /dev/null
+++ b/plugins/color-helper/utils/collect.ts
@@ -0,0 +1,66 @@
+import chroma from 'chroma-js';
+import { db, dbStorage } from './platform';
+import { isDarkColor } from './color';
+
+/**
+ * 收藏颜色的文档形状与写入
+ *
+ * 收藏页按 `color/` 前缀从 db 里捞文档、按 collectsort 里的顺序排,
+ * 换入口收藏必须写出完全一致的形状, 否则收藏页认不出来 ——
+ * 所以 _id 生成与字段拼装集中在这里, 供两个入口共用。
+ */
+
+/** 收藏页排序索引的 dbStorage key, 存收藏文档 _id 的数组 */
+export const COLLECT_SORT_KEY = "collectsort";
+
+export interface CollectColor {
+ _id: string;
+ _rev?: string;
+ name: string;
+ color: string;
+ dark?: boolean;
+}
+
+/** 文档 id: 色值小写、不带 "#"。同一个颜色只可能有一条文档 */
+export function collectDocId(hex: string): string {
+ return "color/" + hex.replace(/^#/, "").toLowerCase();
+}
+
+/** 归一化成 "#RRGGBB" / "#RRGGBBAA"(大写, chroma 3 对半透明色返回 8 位), 非法色值返回 null */
+export function normalizeHex(color: string): string | null {
+ if (typeof color !== "string") return null;
+ const text = color.trim();
+ if (!text || !chroma.valid(text)) return null;
+ const hex = chroma(text).hex().toUpperCase();
+ return /^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(hex) ? hex : null;
+}
+
+/**
+ * 收藏一个颜色, 返回是否**新**收藏成功
+ *
+ * 色值非法、已经收藏过、db 写入失败都返回 false —— 调用方只关心"这次有没有新增"。
+ *
+ * collectsort 一并同步: 收藏页用 indexOf 排序, 索引里没有的 id 会被排到最后,
+ * 不追加的话新收藏的颜色看上去就是"收藏了但不见了"。
+ */
+export function saveCollectedColor(color: string): boolean {
+ const hex = normalizeHex(color);
+ if (!hex) return false;
+
+ const _id = collectDocId(hex);
+ if (db.get(_id)) return false;
+
+ const result = db.put({ _id, name: "", color: hex, dark: isDarkColor(hex) });
+ if (!result?.ok) return false;
+
+ const saved = dbStorage.getItem(COLLECT_SORT_KEY);
+ const order: string[] = Array.isArray(saved)
+ ? saved.filter((id: unknown): id is string => typeof id === "string")
+ : [];
+ // db 里已有、索引里没有的文档一并补上, 否则它们永远排在末尾
+ db.allDocs("color/").forEach((doc: any) => {
+ if (typeof doc?._id === "string" && !order.includes(doc._id)) order.push(doc._id);
+ });
+ dbStorage.setItem(COLLECT_SORT_KEY, order);
+ return true;
+}
diff --git a/plugins/color-helper/utils/color.ts b/plugins/color-helper/utils/color.ts
index db735ffd4..53d252a91 100644
--- a/plugins/color-helper/utils/color.ts
+++ b/plugins/color-helper/utils/color.ts
@@ -1,192 +1,49 @@
import chroma from 'chroma-js';
-import type { ColorObject } from '../types';
/**
- * 解析颜色输入,转换为标准颜色对象
+ * 颜色对比工具
+ *
+ * 只保留被使用的深浅判定与文字配色函数, 其余能力分别在:
+ *
+ * - 图片取色 -> pages/ImagePalettePage.tsx + utils/quantize.ts (Median Cut)
+ * - 色卡保存 -> pages/ImagePalettePage.tsx handleExport + public/preload.cjs
+ * - HSL/格式转换 -> pages/ColorPage.tsx
+ * - 渐变 CSS -> pages/GradientsPage.tsx
*/
-export function parseColor(input: string): ColorObject | null {
- try {
- const color = chroma(input);
- // chroma.js 不验证无效颜色时会抛出异常或返回特殊值
- return {
- hex: color.hex(),
- rgb: {
- r: Math.round(color.rgb()[0]),
- g: Math.round(color.rgb()[1]),
- b: Math.round(color.rgb()[2])
- },
- hsl: {
- h: Math.round(color.hsl()[0]),
- s: Math.round(color.hsl()[1] * 100),
- l: Math.round(color.hsl()[2] * 100)
- },
- hsv: {
- h: Math.round(color.hsv()[0]),
- s: Math.round(color.hsv()[1] * 100),
- v: Math.round(color.hsv()[2] * 100)
- }
- };
- } catch {
- return null;
- }
+/** WCAG 2.x 相对亮度 */
+function relativeLuminance(color: string): number {
+ return chroma(color).luminance();
}
-/**
- * HEX转RGB
- */
-export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
- const color = parseColor(hex);
- return color ? color.rgb : null;
-}
-
-/**
- * RGB转HEX
- */
-export function rgbToHex(r: number, g: number, b: number): string {
- return chroma(r, g, b).hex();
-}
-
-/**
- * HSL转HEX
- */
-export function hslToHex(h: number, s: number, l: number): string {
- try {
- return chroma.hsl(h, s / 100, l / 100).hex();
- } catch {
- return chroma.hsl(h % 360, Math.min(100, Math.max(0, s)) / 100, Math.min(100, Math.max(0, l)) / 100).hex();
- }
-}
-
-/**
- * 生成颜色的浅色变体
- */
-export function lightenColor(color: string, amount: number = 0.2): string {
- return chroma(color).brighten(amount * 10).hex();
-}
-
-/**
- * 生成颜色的深色变体
- */
-export function darkenColor(color: string, amount: number = 0.2): string {
- return chroma(color).darken(amount * 10).hex();
-}
-
-/**
- * 生成互补色
- */
-export function getComplementaryColor(color: string): string {
- const h = chroma(color).hsl()[0];
- return chroma.hsl((h + 180) % 360, chroma(color).hsl()[1], chroma(color).hsl()[2]).hex();
-}
-
-/**
- * 生成 triadic 配色(三色)
- */
-export function getTriadicColors(color: string): string[] {
- const base = chroma(color);
- const hsl = base.hsl();
- return [
- base.hex(),
- chroma.hsl((hsl[0] + 120) % 360, hsl[1], hsl[2]).hex(),
- chroma.hsl((hsl[0] + 240) % 360, hsl[1], hsl[2]).hex()
- ];
+/** WCAG 对比度公式 */
+function contrastRatio(l1: number, l2: number): number {
+ const hi = Math.max(l1, l2);
+ const lo = Math.min(l1, l2);
+ return (hi + 0.05) / (lo + 0.05);
}
/**
- * 生成 analogous 配色(类似色)
+ * 判断颜色是否为深色 —— 即"该底色上应当使用白字"
+ *
+ * 采用 WCAG 对比度做判定(比较白/黑哪个对比度更高), 等价于亮度阈值
+ * luminance < 0.1791。
+ * 判定依据: 相对亮度 0 与 1 对黑/白的对比度相等 => L = sqrt(0.05*1.05)-0.05
*/
-export function getAnalogousColors(color: string, count: number = 5): string[] {
- const base = chroma(color);
- const hsl = base.hsl();
- const colors: string[] = [base.hex()];
-
- for (let i = 1; i < count; i++) {
- colors.push(chroma.hsl((hsl[0] + i * 30) % 360, hsl[1], hsl[2]).hex());
- }
-
- return colors;
-}
-
-/**
- * 生成单色系
- */
-export function getMonochromaticColors(color: string, count: number = 5): string[] {
- return chroma.scale([color, '#fff']).mode('lch').colors(count);
-}
-
-/**
- * 从图片中提取主色调(简化版,实际需要Canvas处理)
- */
-export async function extractColorsFromImage(imageSrc: string, count: number = 5): Promise {
- return new Promise((resolve) => {
- const img = new Image();
- img.crossOrigin = 'Anonymous';
- img.onload = () => {
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
-
- if (!ctx) {
- resolve(['#000000']);
- return;
- }
-
- canvas.width = img.width;
- canvas.height = img.height;
- ctx.drawImage(img, 0, 0);
-
- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
- const data = imageData.data;
- const colorMap: Record = {};
-
- for (let i = 0; i < data.length; i += 4 * 10) {
- const r = data[i];
- const g = data[i + 1];
- const b = data[i + 2];
- const a = data[i + 3];
-
- if (a < 128) continue;
-
- const hex = chroma(r, g, b).hex();
- colorMap[hex] = (colorMap[hex] || 0) + 1;
- }
-
- const sortedColors = Object.entries(colorMap)
- .sort((a, b) => b[1] - a[1])
- .slice(0, count)
- .map(([color]) => color);
-
- resolve(sortedColors.length > 0 ? sortedColors : ['#000000']);
- };
- img.src = imageSrc;
- });
-}
-
-/**
- * 计算两个颜色的相似度(0-1)
- */
-export function getColorSimilarity(color1: string, color2: string): number {
- const c1 = chroma(color1);
- const c2 = chroma(color2);
- return 1 - chroma.deltaE(c1, c2) / 100;
+export function isDarkColor(hex: string): boolean {
+ const l = relativeLuminance(hex);
+ return contrastRatio(l, 1) >= contrastRatio(l, 0);
}
/**
- * 判断颜色是否适合白色文字
+ * 判断颜色是否为浅色(与 isDarkColor 严格互补)
*/
export function isLightColor(color: string): boolean {
- return chroma(color).luminance() > 0.5;
+ return !isDarkColor(color);
}
/**
- * 判断颜色是否为深色(Lab 亮度 < 80)
- */
-export function isDarkColor(hex: string): boolean {
- return chroma(hex).get('lab.l') < 80;
-}
-
-/**
- * 根据背景色深浅返回合适的文字颜色(黑/白)
+ * 根据背景色返回对比度更高的文字颜色(黑/白)
*/
export function textColorFor(hex: string): string {
return isDarkColor(hex) ? '#fff' : '#000';
@@ -202,109 +59,8 @@ export function dimColor(hex: string, alpha: number): string {
}
/**
- * 获取对比色(用于文字)
+ * 获取对比色(用于文字) —— 与 textColorFor 同一判据
*/
export function getContrastColor(color: string): string {
- return isLightColor(color) ? '#000000' : '#FFFFFF';
-}
-
-/**
- * 格式化CSS渐变字符串
- */
-export function formatGradient(colors: string[], angle: number = 90, type: 'linear' | 'radial' = 'linear'): string {
- if (type === 'radial') {
- return `radial-gradient(circle, ${colors.join(', ')})`;
- }
- return `linear-gradient(${angle}deg, ${colors.join(', ')})`;
-}
-
-/**
- * 从base64生成色卡图片
- */
-export function generateColorCardImage(colors: string[], width: number = 540, height: number = 270): Promise {
- return new Promise((resolve) => {
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext('2d');
- if (!ctx) { resolve(''); return; }
-
- const segmentWidth = width / colors.length;
- colors.forEach((color, index) => {
- ctx.fillStyle = color;
- ctx.fillRect(index * segmentWidth, 0, segmentWidth, height);
- });
-
- resolve(canvas.toDataURL('image/png'));
- });
-}
-
-/**
- * 保存色卡图片 - 使用平台 API或浏览器下载
- */
-export async function saveColorCard(colors: string[]): Promise {
- const base64 = await generateColorCardImage(colors);
-
- if (window.services?.saveColorCard) {
- const buffer = base64ToArrayBuffer(base64);
- await window.services.saveColorCard(buffer);
- } else {
- // 浏览器降级: 使用下载方式
- const a = document.createElement('a');
- a.href = base64;
- a.download = 'color-card.png';
- a.click();
- }
-}
-
-/**
- * base64转ArrayBuffer
- */
-function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binaryString = atob(base64.split(',')[1]);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
-}
-
-/**
- * 复制图片到剪贴板
- */
-export async function copyImageToClipboard(base64: string): Promise {
- try {
- const response = await fetch(base64);
- const blob = await response.blob();
- await navigator.clipboard.write([
- new ClipboardItem({ [blob.type]: blob })
- ]);
- } catch {
- // 降级处理
- console.warn('Failed to copy image to clipboard');
- }
-}
-
-/**
- * 读取剪贴板中的图片
- */
-export async function readClipboardImage(): Promise {
- try {
- const items = await navigator.clipboard.read();
- for (const item of items) {
- for (const type of item.types) {
- if (type.startsWith('image/')) {
- const blob = await item.getType(type);
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result as string);
- reader.readAsDataURL(blob);
- });
- }
- }
- }
- return null;
- } catch {
- return null;
- }
+ return isDarkColor(color) ? '#FFFFFF' : '#000000';
}
diff --git a/plugins/color-helper/utils/gradient.ts b/plugins/color-helper/utils/gradient.ts
new file mode 100644
index 000000000..dbd4cce3c
--- /dev/null
+++ b/plugins/color-helper/utils/gradient.ts
@@ -0,0 +1,27 @@
+/**
+ * 渐变几何计算
+ *
+ * 纯数学、零依赖的独立模块, 供 pages/GradientsPage.tsx 使用;
+ * 回归风险最高, 配了 tests/gradient-geometry.test.ts 专门锁住。
+ */
+
+/**
+ * 计算渐变线的起止坐标(用于 Canvas 渲染)
+ * 必须与 CSS linear-gradient(angle) 的渐变线一致:
+ * - 方向向量 d = (sin a, -cos a) (CSS 中 0deg 指向上, y 轴向下)
+ * - 渐变线长度 L = |w·sin a| + |h·cos a| (不是对角线)
+ * - 以画布中心为中点, 向两侧各延伸 L/2
+ * 否则 Canvas 会在端点外钳制色值, 导出图上出现大片纯色区
+ */
+export function calculateGradientLine(width: number, height: number, angle: number) {
+ const rad = angle * Math.PI / 180;
+ const dirX = Math.sin(rad);
+ const dirY = -Math.cos(rad);
+ const half = (Math.abs(width * dirX) + Math.abs(height * dirY)) / 2;
+ return {
+ tx: width / 2 - dirX * half,
+ ty: height / 2 - dirY * half,
+ bx: width / 2 + dirX * half,
+ by: height / 2 + dirY * half,
+ };
+}
diff --git a/plugins/color-helper/utils/platform.ts b/plugins/color-helper/utils/platform.ts
index 68a1062d7..c3630101f 100644
--- a/plugins/color-helper/utils/platform.ts
+++ b/plugins/color-helper/utils/platform.ts
@@ -5,16 +5,13 @@
* - 解耦业务代码与平台 API
*/
-// 平台实例
const platform = window.platform;
-// localStorage key 前缀
const STORAGE_PREFIX = 'color_helper_';
-// 环境检测
export const isPlatform = !!platform;
-// dbStorage 适配
+// dbStorage 适配
export const dbStorage = {
getItem(key: string): any {
if (platform?.dbStorage) {
@@ -63,9 +60,16 @@ export const db = {
}
},
- put(doc: any): { ok: boolean; id: string; rev?: string; error?: string } {
+ put(doc: any): { ok: boolean; id: string; rev?: string; error?: unknown } {
if (platform?.db) {
- return platform.db.put(doc);
+ // 平台分支返回 DbReturn(ok 可选、error 是布尔), 归一化成与降级分支一致的形状
+ const r = platform.db.put(doc);
+ return {
+ ok: r.ok !== false && !r.error,
+ id: r.id,
+ rev: r.rev,
+ error: r.error ? (r.message ?? r.name ?? 'db.put failed') : undefined,
+ };
}
try {
const id = doc._id;
@@ -73,19 +77,23 @@ export const db = {
const rev = existing?._rev ? String(parseInt(existing._rev || '0') + 1) : '1';
const toSave = { ...doc, _rev: rev };
localStorage.setItem(`${STORAGE_PREFIX}db_${id}`, JSON.stringify(toSave));
- return { ok: true, id, rev };
+ return { ok: true, id, rev, error: undefined };
} catch (e: any) {
return { ok: false, id: doc._id, error: e.message };
}
},
- remove(doc: any): { ok: boolean; error?: string } {
+ remove(doc: any): { ok: boolean; error?: unknown } {
if (platform?.db) {
- return platform.db.remove(doc);
+ const r = platform.db.remove(doc);
+ return {
+ ok: r.ok !== false && !r.error,
+ error: r.error ? (r.message ?? r.name ?? 'db.remove failed') : undefined,
+ };
}
try {
localStorage.removeItem(`${STORAGE_PREFIX}db_${doc._id}`);
- return { ok: true };
+ return { ok: true, error: undefined };
} catch (e: any) {
return { ok: false, error: e.message };
}
@@ -93,63 +101,91 @@ export const db = {
allDocs(key?: string): any[] {
if (platform?.db) {
- return platform.db.allDocs(key);
+ try {
+ return platform.db.allDocs(key) ?? [];
+ } catch {
+ return [];
+ }
}
+ const results: any[] = [];
try {
- const results: any[] = [];
const prefix = `${STORAGE_PREFIX}db_`;
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
- if (k && k.startsWith(prefix)) {
- const v = localStorage.getItem(k);
- if (v) {
- const doc = JSON.parse(v);
- if (!key || doc._id.startsWith(key)) {
- results.push(doc);
- }
+ if (!k || !k.startsWith(prefix)) continue;
+ const v = localStorage.getItem(k);
+ if (!v) continue;
+ // 单个 doc 损坏只跳过该条, 不能让整批收藏都消失
+ try {
+ const doc = JSON.parse(v);
+ if (doc && (!key || (typeof doc._id === 'string' && doc._id.startsWith(key)))) {
+ results.push(doc);
}
- }
+ } catch { /* skip corrupt doc */ }
}
- return results;
} catch {
- return [];
+ return results;
}
+ return results;
},
};
// 剪贴板
+
+/** execCommand 回退: 非安全上下文(非 https / file)或 clipboard API 被拒时使用 */
+function copyTextFallback(text: string): void {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+}
+
export function copyText(text: string): void {
if (platform?.copyText) {
platform.copyText(text);
return;
}
+ // navigator.clipboard.writeText() 返回 Promise: API 不存在时同步抛、
+ // 权限被拒时异步 reject —— 同步 try/catch 只接得住前者,
+ // 两处都要落到 copyTextFallback 才算覆盖完整。
+ const clipboard = navigator.clipboard;
+ if (!clipboard?.writeText) {
+ copyTextFallback(text);
+ return;
+ }
try {
- navigator.clipboard.writeText(text);
+ clipboard.writeText(text).catch(() => copyTextFallback(text));
} catch {
- // fallback: textarea copy
- const ta = document.createElement('textarea');
- ta.value = text;
- ta.style.position = 'fixed';
- ta.style.left = '-9999px';
- document.body.appendChild(ta);
- ta.select();
- document.execCommand('copy');
- document.body.removeChild(ta);
+ copyTextFallback(text);
}
}
-export function copyImage(dataUrl: string): void {
+/** 复制图片到剪贴板(仅平台支持); 浏览器无对等 API, 降级为下载
+ * 浏览器不静默丢弃, 否则按钮点击后毫无反应 */
+export function copyImage(dataUrl: string, filename = 'image.png'): void {
if (platform?.copyImage) {
platform.copyImage(dataUrl);
return;
}
- console.warn('[fallback] copyImage not supported in browser');
+ const a = document.createElement('a');
+ a.href = dataUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
}
// 屏幕截图
-export function screenCapture(callback: (imagePath: string) => void): void {
- if ((platform as any)?.screenCapture) {
- (platform as any).screenCapture(callback);
+// 回调第二参 bounds 仅 Windows / Linux 有值(macOS 为 undefined)
+export function screenCapture(
+ callback: (imgBase64: string, bounds?: { x: number; y: number; width: number; height: number }) => void,
+): void {
+ if (platform?.screenCapture) {
+ platform.screenCapture(callback);
return;
}
console.warn('[fallback] screenCapture not supported in browser');
@@ -205,22 +241,31 @@ export function onPluginOut(callback: () => void): void {
}
}
-// AI API
-// 调用方式: platform.ai({model:"doubao-1.5-pro-32k", messages:[...]})
-// 返回 Promise<{content: string}>
+// AI API
+// 调用方式: platform.ai({ messages: [...] }), 一般不要传 model
+// 返回 Promise<{ role, content?: string, reasoning_content?: string }>
export function isAIAvailable(): boolean {
- return !!(platform as any)?.ai && typeof (platform as any).ai === 'function';
+ return !!platform?.ai && typeof platform.ai === 'function';
}
-export async function aiChat(messages: { role: string; content: string }[], model: string = 'doubao-1.5-pro-32k'): Promise<{ content: string }> {
+/**
+ * @param model 留空时宿主会选用用户已配置的默认模型(本地供应商优先)。
+ * 传了宿主解析不出来的 ID 会直接报「未找到 AI 模型配置」, 所以一般不要传。
+ * 确需指定时, 用 `allAiModels()` 取列表并回传其中的 id / value。
+ */
+export async function aiChat(
+ messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
+ model?: string,
+): Promise<{ role?: string; content?: string }> {
if (!isAIAvailable()) {
throw new Error('当前版本不支持 AI 功能');
}
- return (platform as any).ai({ model, messages });
+ return platform!.ai({ model, messages });
}
// 文件对话框
+// 宿主侧弹窗是异步的, 返回值可能是 undefined, 调用方必须容错。
export function showOpenDialog(options: any): string[] | undefined {
if (platform?.showOpenDialog) {
return platform.showOpenDialog(options);
diff --git a/plugins/color-helper/utils/quantize.ts b/plugins/color-helper/utils/quantize.ts
index d197dcecf..fa8623f2f 100644
--- a/plugins/color-helper/utils/quantize.ts
+++ b/plugins/color-helper/utils/quantize.ts
@@ -14,11 +14,6 @@ function naturalOrder(a: number, b: number): number {
return a < b ? -1 : a > b ? 1 : 0;
}
-/** 数组求和 */
-function sumArray(array: number[]): number {
- return array.reduce((a, b) => a + b, 0);
-}
-
/**
* 优先队列 - 存储 VBox 对象,按比较器排序
* 延迟排序: dirty 标记确保只在需要时才排序
@@ -37,7 +32,7 @@ class PQueue {
peek(index?: number): VBox {
if (this.dirty) this.sort();
if (index === undefined) index = this.sorted.length - 1;
- return this.sorted[index];
+ return this.sorted[index]!;
}
pop(): VBox | undefined {
@@ -111,7 +106,7 @@ class VBox {
this._count = c;
this._count_set = true;
}
- return this._count;
+ return this._count!;
}
copy(): VBox {
@@ -168,7 +163,7 @@ class SimpleCMap {
palette(): number[][] {
return this.colors;
}
- map(pixel: number[]): number {
+ map(pixel: number[]): number[] {
return pixel;
}
}
@@ -198,7 +193,7 @@ class CMap {
}
/** 将像素映射到最近的代表色 */
- map(pixel: number[]): number {
+ map(pixel: number[]): number[] {
for (let i = 0; i < this.vboxes.size(); i++) {
if (this.vboxes.peek(i).vbox.contains(pixel)) {
return this.vboxes.peek(i).color;
@@ -208,7 +203,7 @@ class CMap {
}
/** 欧氏距离找最近代表色 */
- private nearest(pixel: number[]): number {
+ private nearest(pixel: number[]): number[] {
let minDist: number | undefined;
let best: number[] = pixel;
for (let i = 0; i < this.vboxes.size(); i++) {
@@ -242,7 +237,7 @@ class CMapPQueue {
peek(index?: number): { vbox: VBox; color: number[] } {
if (this.dirty) this.sort();
if (index === undefined) index = this.sorted.length - 1;
- return this.sorted[index];
+ return this.sorted[index]!;
}
pop(): { vbox: VBox; color: number[] } | undefined {
@@ -366,12 +361,12 @@ function medianCutApply(histo: HistoMap, vbox: VBox): VBox[] | null {
* 4. 按 count*volume 排序后第二轮切分: 目标 maxColors
* 5. 生成 CMap 颜色映射表
*/
-export function quantize(pixels: number[][], maxColors: number): CMap | SimpleCMap | null | false {
+export function quantize(pixels: number[][], maxColors: number): CMap | SimpleCMap | null {
if (!Number.isInteger(maxColors) || maxColors < 1 || maxColors > 256) {
throw new Error('Invalid maximum color count. It must be an integer between 1 and 256.');
}
- if (!pixels.length || maxColors < 2 || maxColors > 256) return false;
+ if (!pixels.length || maxColors < 2) return null;
// 去重
const uniquePixels: number[][] = [];
@@ -413,11 +408,11 @@ export function quantize(pixels: number[][], maxColors: number): CMap | SimpleCM
/** 迭代切分: 从优先队列中取出 VBox 进行中位切分,直到达到目标数量 */
function iterate(pq: PQueue, target: number): void {
- let A = pq.size(); // A = E.size()
+ let A = pq.size();
let I = 0;
while (I < 1000) {
- if (A >= target || I++ > 1000) return; // if(A>=w||I++>1e3)return
+ if (A >= target || I++ > 1000) return;
const P = pq.pop();
if (!P) return;
@@ -426,24 +421,24 @@ export function quantize(pixels: number[][], maxColors: number): CMap | SimpleCM
const B = medianCutApply(histo, P);
const N = B ? B[0] : null;
const j = B && B.length >= 2 ? B[1] : null;
- if (!N) return; // if(!N)return
+ if (!N) return;
pq.push(N);
if (j) {
pq.push(j);
- A++; // j&&(E.push(j),A++)
+ A++;
}
} else {
pq.push(P);
- I++; // I++ (only when count==0)
+ // 只有空盒(不可再切分)才推进计数: 有进展的切分不受 1000 次上限影响
+ I++;
}
}
}
- // 第一次切分: S(b, .75 * u) - u 是 maxColors,不是像素数!
+ // 第一轮切分目标 0.75 * maxColors(是颜色数, 不是像素数)
iterate(pq, 0.75 * maxColors);
- // 排序后放入新队列
- // 先把 pq 中的 vbox 全部取出排序
+ // 第二轮按 count × volume 排序: 大且满的盒子优先继续切分
const allVboxes: VBox[] = [];
while (pq.size() > 0) {
allVboxes.push(pq.pop()!);
diff --git a/plugins/color-helper/utils/storage.ts b/plugins/color-helper/utils/storage.ts
deleted file mode 100644
index d47a28aac..000000000
--- a/plugins/color-helper/utils/storage.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type { CollectedColor } from '../types';
-import { dbStorage } from './platform';
-
-const STORAGE_KEY = 'color-collected';
-
-/**
- * 获取收藏的颜色列表
- */
-export function getCollectedColors(): CollectedColor[] {
- try {
- return dbStorage.getItem(STORAGE_KEY) || [];
- } catch {
- return [];
- }
-}
-
-/**
- * 保存收藏的颜色列表
- */
-export function saveCollectedColors(colors: CollectedColor[]): void {
- try {
- dbStorage.setItem(STORAGE_KEY, colors);
- } catch (error) {
- console.error('Failed to save collected colors:', error);
- }
-}
-
-/**
- * 添加收藏颜色
- */
-export function addColoredColor(color: string, name?: string): CollectedColor {
- const colors = getCollectedColors();
- const newColor: CollectedColor = {
- id: Date.now().toString(),
- name: name || color,
- color,
- createdAt: Date.now()
- };
-
- colors.unshift(newColor);
- saveCollectedColors(colors);
-
- return newColor;
-}
-
-/**
- * 删除收藏颜色
- */
-export function removeCollectedColor(id: string): void {
- const colors = getCollectedColors();
- const filtered = colors.filter(c => c.id !== id);
- saveCollectedColors(filtered);
-}
-
-/**
- * 更新收藏颜色名称
- */
-export function updateColoredColorName(id: string, name: string): void {
- const colors = getCollectedColors();
- const updated = colors.map(c =>
- c.id === id ? { ...c, name } : c
- );
- saveCollectedColors(updated);
-}
diff --git a/plugins/color-helper/vite-env.d.ts b/plugins/color-helper/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/plugins/color-helper/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/plugins/color-helper/vite.config.ts b/plugins/color-helper/vite.config.ts
index 5dee9864c..cecf58e7d 100644
--- a/plugins/color-helper/vite.config.ts
+++ b/plugins/color-helper/vite.config.ts
@@ -3,5 +3,5 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
- base: './'
+ base: './',
})
diff --git a/plugins/color-helper/vitest.config.ts b/plugins/color-helper/vitest.config.ts
new file mode 100644
index 000000000..ddb3816d2
--- /dev/null
+++ b/plugins/color-helper/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ // utils/platform.ts 在模块顶层读 window.platform, node 环境会直接抛
+ // "window is not defined" —— 这里提供 DOM 与 localStorage
+ environment: 'happy-dom',
+ include: ['tests/**/*.test.ts'],
+ },
+});