Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 774495912c | |||
| 57d006a39b | |||
| ffda0b9099 | |||
| 8b880a19e2 | |||
| e34675a931 | |||
| 642cb71b93 | |||
| 352db2c4c0 | |||
| 983ebead02 | |||
| 9a6ec3a396 | |||
| 4710a56242 | |||
| aae02d1502 | |||
| 75d8cbbfc3 | |||
| 68f142dfa3 | |||
| 24b32ea5cc | |||
| f56397fb97 | |||
| 844acad316 | |||
| 23e0867027 | |||
| acbc62463e | |||
| ffe180f482 | |||
| 91e010cff7 | |||
| d5cec8342d | |||
| c39d131a86 | |||
| d23deca0e7 | |||
| 0ec968c344 | |||
| bca874fd30 | |||
| 1353dc3145 | |||
| d3de7cc172 | |||
| 3eb0246c40 | |||
| 93373adb9a | |||
| 91cfb3d068 | |||
| 4c61e79d0d | |||
| fb985edb20 | |||
| a9d6856ab8 | |||
| b26c8886cf | |||
| 3e671cce47 | |||
| 4d3892a5ea | |||
| df5bbc739c | |||
| 6711401f5f | |||
| e2dbbcb884 | |||
| 71c416caab |
@@ -12,6 +12,7 @@ dist
|
||||
dist-ssr
|
||||
.ssr
|
||||
.output
|
||||
docs/icons
|
||||
*.local
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe("SelectTrigger", () => {
|
||||
it("renders the disclosure icon without forwarding fallback children to its svg", () => {
|
||||
render(
|
||||
<Select items={[{ label: "System", value: "system" }]} value="system">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole("combobox")
|
||||
const icon = trigger.querySelector("[aria-hidden='true'] > svg")
|
||||
|
||||
expect(icon).not.toBeNull()
|
||||
expect(icon?.childNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -183,7 +183,7 @@ function Field({
|
||||
Component: FieldInputComponent
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="relative flex h-12 items-center rounded-lg border-2 border-border p-0 focus-within:border-primary focus-within:[&_label]:opacity-100">
|
||||
<fieldset className="relative flex h-12 items-center rounded-lg border-2 border-border p-0 focus-within:border-accent focus-within:[&_label]:opacity-100">
|
||||
<legend className="ml-2 flex h-0 px-0">
|
||||
<label
|
||||
className="h-fit -translate-y-1/2 px-1 text-sm opacity-50"
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -55,8 +55,15 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@icones/react": "^0.0.1",
|
||||
"@shikijs/core": "^4.4.3",
|
||||
"@shikijs/engine-javascript": "^4.4.3",
|
||||
"@shikijs/langs": "^4.4.3",
|
||||
"@shikijs/themes": "^4.4.3",
|
||||
"@workspace/appearance": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/media": "workspace:*",
|
||||
"@workspace/overlays": "workspace:*",
|
||||
"@workspace/page": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/search": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -76,11 +83,33 @@
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"satteri": "^0.10.5",
|
||||
"satteri-expressive-code": "^0.1.24",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-satteri": "^0.3.5",
|
||||
"wrangler": "^4.135.0",
|
||||
"yaml": "^2.9.1",
|
||||
},
|
||||
},
|
||||
"packages/appearance": {
|
||||
"name": "@workspace/appearance",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/blocks": {
|
||||
@@ -110,6 +139,23 @@
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/cropper": {
|
||||
"name": "@workspace/cropper",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@workspace/i18n",
|
||||
"version": "0.0.0",
|
||||
@@ -140,6 +186,23 @@
|
||||
"vitest": "^4",
|
||||
},
|
||||
},
|
||||
"packages/layout": {
|
||||
"name": "@workspace/layout",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/lexical": {
|
||||
"name": "@workspace/lexical",
|
||||
"version": "0.0.0",
|
||||
@@ -185,6 +248,43 @@
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/overlays": {
|
||||
"name": "@workspace/overlays",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/page": {
|
||||
"name": "@workspace/page",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/preferences": {
|
||||
"name": "@workspace/preferences",
|
||||
"version": "0.0.0",
|
||||
@@ -201,6 +301,23 @@
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/record": {
|
||||
"name": "@workspace/record",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"es-toolkit": "^1.50.0",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/search": {
|
||||
"name": "@workspace/search",
|
||||
"version": "0.0.0",
|
||||
@@ -375,6 +492,8 @@
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.1", "", {}, "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A=="],
|
||||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
|
||||
|
||||
"@devframes/hub": ["@devframes/hub@0.7.14", "", { "dependencies": { "birpc": "^4.0.0", "destr": "^2.0.5", "nostics": "^1.2.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyexec": "^1.2.4", "zigpty": "^0.2.1" }, "peerDependencies": { "devframe": "0.7.14" } }, "sha512-Ym3YHkBnpdwhPw7y4YgURZYntQPShb9raRfo60D1Yk4jb1OlnL2GGHw6pt4iLZqHL4vp1P4T6YpBFPPVx4G38A=="],
|
||||
@@ -451,6 +570,14 @@
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@expressive-code/core": ["@expressive-code/core@0.42.0", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-MN11+9nfmaC7sYu2BZJXAXqwkBRt8t1xTSqP+Ti1NfTEskgl6xUnzDxoaiQkg0BMzpglA0pys4dpDKquP/cyIw=="],
|
||||
|
||||
"@expressive-code/plugin-frames": ["@expressive-code/plugin-frames@0.42.0", "", { "dependencies": { "@expressive-code/core": "^0.42.0" } }, "sha512-XtkPm+941Uta7Y+81Acv+OA/20F1NJmJhCX6UYGKpqEIGqplNh3PTOhcURp6tcruhlzJcWcvpWy6Oigz3SrjqA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki": ["@expressive-code/plugin-shiki@0.42.0", "", { "dependencies": { "@expressive-code/core": "^0.42.0", "shiki": "^4.0.2" } }, "sha512-PMKey/kLmewttAHQezL+Y5Fx3vVssfDi3+FJOYQQS2mXP3tQspFELtKKAfsXfmSXdToZYgwoO69HJndqfE+09g=="],
|
||||
|
||||
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.42.0", "", { "dependencies": { "@expressive-code/core": "^0.42.0" } }, "sha512-l59lUx8fq1v5g6SpmbDjiU0+7IdfbiWnAyRmtTVSpfhyq+nZMN4UcmYyu2b9Mynhzt7Gr+O+cXyEPDNb2AVWVQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
@@ -793,6 +920,22 @@
|
||||
|
||||
"@shadcn/react": ["@shadcn/react@0.2.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
@@ -959,6 +1102,8 @@
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="],
|
||||
|
||||
"@vitejs/devtools": ["@vitejs/devtools@0.4.9", "", { "dependencies": { "@devframes/hub": "^0.7.14", "@devframes/plugin-inspect": "^0.7.14", "@devframes/plugin-messages": "^0.7.14", "@devframes/plugin-terminals": "^0.7.14", "@vitejs/devtools-kit": "0.4.9", "birpc": "^4.0.0", "cac": "^7.0.0", "devframe": "^0.7.14", "h3": "2.0.1-rc.22", "local-pkg": "^1.2.1", "mlly": "^1.8.2", "nostics": "^1.2.0", "obug": "^2.1.4", "pathe": "^2.0.3", "vue": "^3.5.40" }, "peerDependencies": { "@vitejs/devtools-oxc": "^0.4.9", "@vitejs/devtools-rolldown": "^0.4.9", "@vitejs/devtools-vite": "^0.4.9", "@vitejs/devtools-vitest": "^0.4.9", "vite": "*" }, "optionalPeers": ["@vitejs/devtools-oxc", "@vitejs/devtools-rolldown", "@vitejs/devtools-vite", "@vitejs/devtools-vitest"], "bin": { "vite-devtools": "./bin.js" } }, "sha512-Q9ju+eLFn/ge1nkgVUhAU0Yfu7lEITApK7f4r6z71sYRcTqIMqksMC/qimyAiz4/rQyL7WNj2hTFMIeYT7wolQ=="],
|
||||
@@ -999,16 +1144,28 @@
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.40", "", {}, "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg=="],
|
||||
|
||||
"@workspace/appearance": ["@workspace/appearance@workspace:packages/appearance"],
|
||||
|
||||
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
|
||||
|
||||
"@workspace/cropper": ["@workspace/cropper@workspace:packages/cropper"],
|
||||
|
||||
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
||||
|
||||
"@workspace/layout": ["@workspace/layout@workspace:packages/layout"],
|
||||
|
||||
"@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"],
|
||||
|
||||
"@workspace/media": ["@workspace/media@workspace:packages/media"],
|
||||
|
||||
"@workspace/overlays": ["@workspace/overlays@workspace:packages/overlays"],
|
||||
|
||||
"@workspace/page": ["@workspace/page@workspace:packages/page"],
|
||||
|
||||
"@workspace/preferences": ["@workspace/preferences@workspace:packages/preferences"],
|
||||
|
||||
"@workspace/record": ["@workspace/record@workspace:packages/record"],
|
||||
|
||||
"@workspace/search": ["@workspace/search@workspace:packages/search"],
|
||||
|
||||
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
|
||||
@@ -1053,6 +1210,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ=="],
|
||||
|
||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
|
||||
@@ -1061,6 +1220,8 @@
|
||||
|
||||
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -1083,10 +1244,16 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
@@ -1109,6 +1276,8 @@
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="],
|
||||
@@ -1137,6 +1306,8 @@
|
||||
|
||||
"crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
@@ -1201,8 +1372,12 @@
|
||||
|
||||
"devframe": ["devframe@0.7.14", "", { "dependencies": { "@valibot/to-json-schema": "^1.7.1", "birpc": "^4.0.0", "crossws": "^0.4.10", "destr": "^2.0.5", "h3": "^2.0.1-rc.22", "mrmime": "^2.0.1", "nostics": "^1.2.0", "pathe": "^2.0.3", "ufo": "^1.6.4", "valibot": "^1.4.2" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "cac": "^7.0.0" }, "optionalPeers": ["@modelcontextprotocol/sdk", "cac"] }, "sha512-AtGfI3LKjZL11eBcGArZn6QMZTrmGZY7DCwJ9Wot8Vt6/jqaV4wv/Z/VsYneGc4Z/OmRmHucfMB0xlDrE6GYJw=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"docs": ["docs@workspace:docs"],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
@@ -1279,6 +1454,8 @@
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.42.0", "", { "dependencies": { "@expressive-code/core": "^0.42.0", "@expressive-code/plugin-frames": "^0.42.0", "@expressive-code/plugin-shiki": "^0.42.0", "@expressive-code/plugin-text-markers": "^0.42.0" } }, "sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g=="],
|
||||
|
||||
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -1343,12 +1520,32 @@
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
|
||||
|
||||
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
|
||||
|
||||
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||
|
||||
"hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
|
||||
|
||||
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="],
|
||||
|
||||
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"httpxy": ["httpxy@0.5.5", "", {}, "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA=="],
|
||||
@@ -1495,6 +1692,8 @@
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
|
||||
@@ -1505,6 +1704,16 @@
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
@@ -1545,6 +1754,8 @@
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"nypm": ["nypm@0.6.9", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -1567,6 +1778,10 @@
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
|
||||
@@ -1619,6 +1834,8 @@
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
@@ -1631,6 +1848,8 @@
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pseudolocale": ["pseudolocale@2.3.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "pseudolocale": "dist/cli.mjs" } }, "sha512-2RfZuwSSZ8sopelTIIZ2JhmO4GLnHflJQBmtMPF2APWEtmfKOsvqCIWsi8KQJ6EQ0D0+zVllPnLLGijUauSlqw=="],
|
||||
@@ -1675,6 +1894,12 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
|
||||
@@ -1699,6 +1924,8 @@
|
||||
|
||||
"satteri": ["satteri@0.10.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.10.5", "@bruits/satteri-darwin-x64": "0.10.5", "@bruits/satteri-linux-arm64-gnu": "0.10.5", "@bruits/satteri-linux-arm64-musl": "0.10.5", "@bruits/satteri-linux-x64-gnu": "0.10.5", "@bruits/satteri-linux-x64-musl": "0.10.5", "@bruits/satteri-wasm32-wasi": "0.10.5", "@bruits/satteri-win32-arm64-msvc": "0.10.5", "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ=="],
|
||||
|
||||
"satteri-expressive-code": ["satteri-expressive-code@0.1.24", "", { "dependencies": { "expressive-code": "^0.42.0" }, "peerDependencies": { "satteri": ">=0.4.0" } }, "sha512-051Y0jA0qZlTL65e9M+xSxKSUO8msCPzRPASZ93FwBV9uYTil1+RNK6KwuIMidpzBFhxc4c8mgARaQOu1RcX/g=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
@@ -1723,6 +1950,8 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
@@ -1745,6 +1974,8 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
@@ -1757,6 +1988,8 @@
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
@@ -1803,6 +2036,8 @@
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
|
||||
@@ -1827,6 +2062,18 @@
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
@@ -1851,6 +2098,10 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
||||
@@ -1915,6 +2166,8 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
@@ -2001,6 +2254,8 @@
|
||||
|
||||
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
+11
-2
@@ -12,8 +12,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@icones/react": "^0.0.1",
|
||||
"@shikijs/core": "^4.4.3",
|
||||
"@shikijs/engine-javascript": "^4.4.3",
|
||||
"@shikijs/langs": "^4.4.3",
|
||||
"@shikijs/themes": "^4.4.3",
|
||||
"@workspace/appearance": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/media": "workspace:*",
|
||||
"@workspace/overlays": "workspace:*",
|
||||
"@workspace/page": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/search": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -33,10 +40,12 @@
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"satteri": "^0.10.5",
|
||||
"satteri-expressive-code": "^0.1.24",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-satteri": "^0.3.5",
|
||||
"wrangler": "^4.135.0"
|
||||
"wrangler": "^4.135.0",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { cn } from "../lib/utils"
|
||||
import { useTranslate } from "../lib/locale"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
import React from "react"
|
||||
import { SearchTrigger } from "./search-trigger"
|
||||
import { SettingsLink } from "./settings-link"
|
||||
import { getDocsHomeHref } from "../content/registry"
|
||||
|
||||
export const rootCSSVariables = {
|
||||
"--cw": "60rem",
|
||||
@@ -37,7 +38,7 @@ export function HomeButton({
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400 shrink-0"
|
||||
>
|
||||
<Icon name="tabler:smart-home" className="size-5" />
|
||||
{label ?? t({ zh: "首页", us: "Home" })}
|
||||
@@ -83,11 +84,109 @@ export function SlashLine({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Actions({ className }: { className?: string }) {
|
||||
export function Actions() {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)}>
|
||||
<>
|
||||
<SearchTrigger />
|
||||
<SettingsLink />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
banner,
|
||||
bottom,
|
||||
leading,
|
||||
subleading,
|
||||
actions,
|
||||
locale,
|
||||
...rest
|
||||
}: React.ComponentProps<"header"> & {
|
||||
banner?: React.ReactNode
|
||||
bottom?: React.ReactNode
|
||||
leading?: React.ReactNode
|
||||
subleading?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
locale?: string
|
||||
}) {
|
||||
return (
|
||||
<header {...rest}>
|
||||
{banner}
|
||||
<HeaderInner
|
||||
leading={leading}
|
||||
subleading={subleading}
|
||||
locale={locale}
|
||||
actions={actions}
|
||||
/>
|
||||
{bottom}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderInner({
|
||||
leading,
|
||||
subleading,
|
||||
actions,
|
||||
locale,
|
||||
}: {
|
||||
leading?: React.ReactNode
|
||||
subleading?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
locale?: string
|
||||
}) {
|
||||
const resolvedLocale = useLocale() ?? locale
|
||||
const hasLeading = React.Children.count(leading) > 0
|
||||
const hasSubleading = React.Children.count(subleading) > 0
|
||||
|
||||
return (
|
||||
<Container className="flex size-full items-center gap-4">
|
||||
{hasLeading || hasSubleading ? (
|
||||
<>
|
||||
{hasLeading ? (
|
||||
leading
|
||||
) : (
|
||||
<HomeButton href={getDocsHomeHref(resolvedLocale)} />
|
||||
)}
|
||||
{hasSubleading ? (
|
||||
<>
|
||||
<SlashLine />
|
||||
{subleading}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<h1 className="min-w-0 text-2xl font-semibold sm:text-3xl">
|
||||
<span>S</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">imple</span>
|
||||
<span> </span>
|
||||
<span>R</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">eact</span>
|
||||
<span> </span>
|
||||
<span>A</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">pp</span>
|
||||
<span> </span>
|
||||
<span>K</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">it</span>
|
||||
</h1>
|
||||
)}
|
||||
{actions != null ? (
|
||||
<div className="-mr-2 ml-auto inline-flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteFooter({
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer className={cn("py-8", className)} {...rest}>
|
||||
<Container className="text-center text-slate-500 dark:text-slate-400">
|
||||
<div>Copyright © 2026</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MdxComponents } from "../content/types"
|
||||
import { Sandbox, SingleFileSandbox } from "./sandbox"
|
||||
|
||||
export const mdxComponents: MdxComponents = {
|
||||
a: MdxAnchor,
|
||||
@@ -7,6 +8,8 @@ export const mdxComponents: MdxComponents = {
|
||||
h2: MdxHeading2,
|
||||
h3: MdxHeading3,
|
||||
pre: MdxPre,
|
||||
Sandbox,
|
||||
SingleFileSandbox,
|
||||
}
|
||||
|
||||
function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { HighlighterCore } from "@shikijs/core"
|
||||
|
||||
export type HighlightedToken = {
|
||||
content: string
|
||||
dark: string
|
||||
light: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export type HighlightedLine = {
|
||||
offset: number
|
||||
tokens: HighlightedToken[]
|
||||
}
|
||||
|
||||
export type HighlightedSource = HighlightedLine[]
|
||||
|
||||
const languageLoaders = {
|
||||
bash: () => import("@shikijs/langs/bash"),
|
||||
css: () => import("@shikijs/langs/css"),
|
||||
html: () => import("@shikijs/langs/html"),
|
||||
javascript: () => import("@shikijs/langs/javascript"),
|
||||
json: () => import("@shikijs/langs/json"),
|
||||
jsx: () => import("@shikijs/langs/jsx"),
|
||||
markdown: () => import("@shikijs/langs/markdown"),
|
||||
mdx: () => import("@shikijs/langs/mdx"),
|
||||
tsx: () => import("@shikijs/langs/tsx"),
|
||||
typescript: () => import("@shikijs/langs/typescript"),
|
||||
yaml: () => import("@shikijs/langs/yaml"),
|
||||
} as const
|
||||
|
||||
type SyntaxLanguage = keyof typeof languageLoaders
|
||||
|
||||
const languageByExtension: Readonly<Record<string, SyntaxLanguage>> = {
|
||||
bash: "bash",
|
||||
cjs: "javascript",
|
||||
css: "css",
|
||||
html: "html",
|
||||
js: "javascript",
|
||||
json: "json",
|
||||
jsx: "jsx",
|
||||
md: "markdown",
|
||||
mdx: "mdx",
|
||||
mjs: "javascript",
|
||||
sh: "bash",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
zsh: "bash",
|
||||
}
|
||||
|
||||
const highlighterPromises = new Map<SyntaxLanguage, Promise<HighlighterCore>>()
|
||||
|
||||
export async function highlightSource(
|
||||
source: string,
|
||||
filename: string
|
||||
): Promise<HighlightedSource> {
|
||||
const extension = filename.split(".").pop()?.toLowerCase() ?? ""
|
||||
const language = languageByExtension[extension]
|
||||
if (!language) return createPlainSource(source)
|
||||
|
||||
const highlighter = await getHighlighter(language)
|
||||
const lines = highlighter.codeToTokensWithThemes(source, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
})
|
||||
|
||||
let lineOffset = 0
|
||||
|
||||
return lines.map((line) => {
|
||||
const highlightedLine: HighlightedLine = {
|
||||
offset: lineOffset,
|
||||
tokens: line.map((token) => ({
|
||||
content: token.content,
|
||||
dark: token.variants.dark?.color ?? "currentColor",
|
||||
light: token.variants.light?.color ?? "currentColor",
|
||||
offset: token.offset,
|
||||
})),
|
||||
}
|
||||
|
||||
lineOffset += line.reduce(
|
||||
(length, token) => length + token.content.length,
|
||||
0
|
||||
)
|
||||
lineOffset += 1
|
||||
return highlightedLine
|
||||
})
|
||||
}
|
||||
|
||||
function getHighlighter(language: SyntaxLanguage) {
|
||||
const cached = highlighterPromises.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const highlighter = Promise.all([
|
||||
import("@shikijs/core"),
|
||||
import("@shikijs/engine-javascript"),
|
||||
import("@shikijs/themes/github-dark"),
|
||||
import("@shikijs/themes/github-light"),
|
||||
languageLoaders[language](),
|
||||
]).then(
|
||||
([
|
||||
{ createHighlighterCore },
|
||||
{ createJavaScriptRegexEngine },
|
||||
{ default: githubDark },
|
||||
{ default: githubLight },
|
||||
{ default: grammar },
|
||||
]) =>
|
||||
createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: grammar,
|
||||
themes: [githubDark, githubLight],
|
||||
})
|
||||
)
|
||||
|
||||
highlighterPromises.set(language, highlighter)
|
||||
return highlighter
|
||||
}
|
||||
|
||||
function createPlainSource(source: string): HighlightedSource {
|
||||
let offset = 0
|
||||
|
||||
return source.split("\n").map((content) => {
|
||||
const line: HighlightedLine = {
|
||||
offset,
|
||||
tokens: [
|
||||
{
|
||||
content,
|
||||
dark: "inherit",
|
||||
light: "inherit",
|
||||
offset,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
offset += content.length + 1
|
||||
return line
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
Sandbox,
|
||||
SingleFileSandbox,
|
||||
type SandboxProps,
|
||||
type SingleFileSandboxProps,
|
||||
} from "./sandbox"
|
||||
export {
|
||||
defineSandbox,
|
||||
type SandboxDefinition,
|
||||
type SandboxLocalizedText,
|
||||
type SandboxPreviewProps,
|
||||
type SandboxPreviewModule,
|
||||
type SandboxSource,
|
||||
type SandboxSourceModule,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SandboxDefinition } from "./types"
|
||||
|
||||
type SandboxManifestModule = {
|
||||
default: SandboxDefinition
|
||||
}
|
||||
|
||||
const manifestModules = import.meta.glob<SandboxManifestModule>(
|
||||
[
|
||||
"../../sandboxes/*/*/sandbox.ts",
|
||||
"../../../../packages/*/docs/sandboxes/*/sandbox.ts",
|
||||
],
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
const sandboxDefinitions = new Map<string, SandboxDefinition>()
|
||||
|
||||
for (const module of Object.values(manifestModules)) {
|
||||
const definition = module.default
|
||||
if (sandboxDefinitions.has(definition.id)) {
|
||||
throw new Error(`Duplicate sandbox id: ${definition.id}`)
|
||||
}
|
||||
sandboxDefinitions.set(definition.id, definition)
|
||||
}
|
||||
|
||||
export function getSandboxDefinition(id: string) {
|
||||
return sandboxDefinitions.get(id)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "*?raw" {
|
||||
const source: string
|
||||
export default source
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
|
||||
type SandboxErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback: (error: Error) => React.ReactNode
|
||||
}
|
||||
|
||||
type SandboxErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class SandboxErrorBoundary extends React.Component<
|
||||
SandboxErrorBoundaryProps,
|
||||
SandboxErrorBoundaryState
|
||||
> {
|
||||
state: SandboxErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.error
|
||||
? this.props.fallback(this.state.error)
|
||||
: this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import type { DocLocale } from "../../content/types"
|
||||
|
||||
const minimumFrameHeight = 288
|
||||
|
||||
export function SandboxFrame({
|
||||
children,
|
||||
fill = false,
|
||||
locale,
|
||||
reloadKey,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
fill?: boolean
|
||||
locale: DocLocale
|
||||
reloadKey: number
|
||||
title: string
|
||||
}) {
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null)
|
||||
const [mountNode, setMountNode] = React.useState<HTMLElement | null>(null)
|
||||
const [height, setHeight] = React.useState(minimumFrameHeight)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const frame = frameRef.current
|
||||
const frameDocument = frame?.contentDocument
|
||||
if (!frame || !frameDocument) return
|
||||
|
||||
frameDocument.open()
|
||||
frameDocument.write(
|
||||
'<!doctype html><html><head></head><body><div id="sandbox-root"></div></body></html>'
|
||||
)
|
||||
frameDocument.close()
|
||||
|
||||
for (const node of document.head.querySelectorAll(
|
||||
'link[rel="stylesheet"], style'
|
||||
)) {
|
||||
frameDocument.head.append(node.cloneNode(true))
|
||||
}
|
||||
|
||||
const frameOverrides = frameDocument.createElement("style")
|
||||
frameOverrides.textContent = `
|
||||
html, body, #sandbox-root {
|
||||
min-height: 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
`
|
||||
frameDocument.head.append(frameOverrides)
|
||||
|
||||
const syncDocumentTheme = () => {
|
||||
frameDocument.documentElement.className =
|
||||
document.documentElement.className
|
||||
frameDocument.documentElement.style.colorScheme =
|
||||
document.documentElement.style.colorScheme
|
||||
}
|
||||
syncDocumentTheme()
|
||||
frameDocument.documentElement.lang = locale
|
||||
|
||||
const themeObserver = new MutationObserver(syncDocumentTheme)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributeFilter: ["class", "style"],
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
const nextMountNode = frameDocument.getElementById("sandbox-root")
|
||||
setMountNode(nextMountNode)
|
||||
|
||||
return () => {
|
||||
themeObserver.disconnect()
|
||||
setMountNode(null)
|
||||
}
|
||||
}, [locale, reloadKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mountNode || fill) return
|
||||
|
||||
const frameDocument = mountNode.ownerDocument
|
||||
const updateHeight = () => {
|
||||
setHeight(
|
||||
Math.max(
|
||||
minimumFrameHeight,
|
||||
Math.ceil(frameDocument.documentElement.scrollHeight)
|
||||
)
|
||||
)
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(updateHeight)
|
||||
resizeObserver.observe(frameDocument.body)
|
||||
updateHeight()
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [fill, mountNode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className={`block w-full border-0 bg-white dark:bg-slate-950 ${fill ? "min-h-0 flex-1" : ""}`}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
sandbox="allow-same-origin"
|
||||
title={title}
|
||||
/>
|
||||
{mountNode ? createPortal(children, mountNode) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import React from "react"
|
||||
import { Icon } from "@icones/react"
|
||||
|
||||
import { useLocale, useTranslate } from "../../lib/locale"
|
||||
import { getSandboxDefinition } from "./registry"
|
||||
import { SandboxErrorBoundary } from "./sandbox-error-boundary"
|
||||
import { SandboxFrame } from "./sandbox-frame"
|
||||
import { highlightSource, type HighlightedSource } from "./highlight-source"
|
||||
import type {
|
||||
SandboxDefinition,
|
||||
SandboxLocalizedText,
|
||||
SandboxPreviewProps,
|
||||
SandboxPreviewModule,
|
||||
SandboxSource,
|
||||
} from "./types"
|
||||
|
||||
type SandboxView = "code" | "preview"
|
||||
|
||||
const codeFontSizes = [12, 13, 14, 16] as const
|
||||
|
||||
export type SandboxProps = {
|
||||
className?: string
|
||||
example: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function Sandbox({ className, example, title }: SandboxProps) {
|
||||
const definition = getSandboxDefinition(example)
|
||||
const t = useTranslate()
|
||||
|
||||
if (!definition) {
|
||||
return (
|
||||
<SandboxMessage className={className} tone="error">
|
||||
{t({
|
||||
zh: `找不到 Sandbox 示例:${example}`,
|
||||
us: `Sandbox example not found: ${example}`,
|
||||
})}
|
||||
</SandboxMessage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SandboxViewer
|
||||
className={className}
|
||||
definition={definition}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SingleFileSandboxProps = {
|
||||
className?: string
|
||||
component: React.ComponentType
|
||||
filename?: string
|
||||
source: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function SingleFileSandbox({
|
||||
className,
|
||||
component,
|
||||
filename = "App.tsx",
|
||||
source,
|
||||
title,
|
||||
}: SingleFileSandboxProps) {
|
||||
const definition = React.useMemo<SandboxDefinition>(
|
||||
() => ({
|
||||
defaultFile: filename,
|
||||
entry: async () => ({
|
||||
default: component as React.ComponentType<SandboxPreviewProps>,
|
||||
}),
|
||||
files: { [filename]: source },
|
||||
id: `single-file:${filename}`,
|
||||
title,
|
||||
}),
|
||||
[component, filename, source, title]
|
||||
)
|
||||
|
||||
return <SandboxViewer className={className} definition={definition} />
|
||||
}
|
||||
|
||||
function SandboxViewer({
|
||||
className,
|
||||
definition,
|
||||
title,
|
||||
}: {
|
||||
className?: string
|
||||
definition: SandboxDefinition
|
||||
title?: SandboxLocalizedText
|
||||
}) {
|
||||
const rootRef = React.useRef<HTMLElement>(null)
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const filenames = React.useMemo(
|
||||
() => Object.keys(definition.files),
|
||||
[definition.files]
|
||||
)
|
||||
const [view, setView] = React.useState<SandboxView>("preview")
|
||||
const [selectedFile, setSelectedFile] = React.useState(
|
||||
definition.defaultFile ?? filenames[0] ?? ""
|
||||
)
|
||||
const [previewModule, setPreviewModule] =
|
||||
React.useState<SandboxPreviewModule | null>(null)
|
||||
const [previewError, setPreviewError] = React.useState<Error | null>(null)
|
||||
const [source, setSource] = React.useState<string | null>(null)
|
||||
const [sourceError, setSourceError] = React.useState<Error | null>(null)
|
||||
const [highlightedSource, setHighlightedSource] =
|
||||
React.useState<HighlightedSource | null>(null)
|
||||
const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1)
|
||||
const [reloadKey, setReloadKey] = React.useState(0)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [fullscreen, setFullscreen] = React.useState(false)
|
||||
const resolvedTitle = resolveSandboxText(
|
||||
title ?? definition.title,
|
||||
locale,
|
||||
definition.id
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
|
||||
const ownerDocument = root.ownerDocument
|
||||
const syncFullscreen = () => {
|
||||
setFullscreen(ownerDocument.fullscreenElement === root)
|
||||
}
|
||||
|
||||
syncFullscreen()
|
||||
ownerDocument.addEventListener("fullscreenchange", syncFullscreen)
|
||||
return () => {
|
||||
ownerDocument.removeEventListener("fullscreenchange", syncFullscreen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
setPreviewModule(null)
|
||||
setPreviewError(null)
|
||||
|
||||
definition.entry().then(
|
||||
(module) => {
|
||||
if (active) setPreviewModule(module)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (active) setPreviewError(toError(error))
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [definition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (view !== "code") return
|
||||
|
||||
const file = definition.files[selectedFile]
|
||||
if (!file) {
|
||||
setSource(null)
|
||||
setSourceError(new Error(`Source file not found: ${selectedFile}`))
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setSource(null)
|
||||
setSourceError(null)
|
||||
setHighlightedSource(null)
|
||||
resolveSource(file).then(
|
||||
(value) => {
|
||||
if (active) setSource(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (active) setSourceError(toError(error))
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [definition.files, selectedFile, view])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!source || view !== "code") return
|
||||
|
||||
let active = true
|
||||
setHighlightedSource(null)
|
||||
highlightSource(source, selectedFile).then(
|
||||
(value) => {
|
||||
if (active) setHighlightedSource(value)
|
||||
},
|
||||
() => {
|
||||
if (active) setHighlightedSource(null)
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedFile, source, view])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedFile(definition.defaultFile ?? filenames[0] ?? "")
|
||||
}, [definition.defaultFile, filenames])
|
||||
|
||||
const copySource = async () => {
|
||||
if (!source) return
|
||||
await navigator.clipboard.writeText(source)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
|
||||
const ownerDocument = root.ownerDocument
|
||||
if (ownerDocument.fullscreenElement === root) {
|
||||
await ownerDocument.exitFullscreen()
|
||||
return
|
||||
}
|
||||
|
||||
if (ownerDocument.fullscreenElement) {
|
||||
await ownerDocument.exitFullscreen()
|
||||
}
|
||||
await root.requestFullscreen()
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={rootRef}
|
||||
className={`not-prose overflow-hidden border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 ${fullscreen ? "m-0 flex h-dvh w-dvw flex-col rounded-none border-0" : "my-6 rounded-xl"} ${className ?? ""}`}
|
||||
aria-label={resolvedTitle}
|
||||
>
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-2 py-1.5 dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<div className="inline-flex rounded-lg bg-slate-200/70 p-0.5 dark:bg-slate-800">
|
||||
<SandboxTab
|
||||
active={view === "preview"}
|
||||
onClick={() => setView("preview")}
|
||||
>
|
||||
<Icon name="lucide:play" className="size-3.5" />
|
||||
{t({ zh: "预览", us: "Preview" })}
|
||||
</SandboxTab>
|
||||
<SandboxTab active={view === "code"} onClick={() => setView("code")}>
|
||||
<Icon name="lucide:code-2" className="size-3.5" />
|
||||
{t({ zh: "代码", us: "Code" })}
|
||||
</SandboxTab>
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 truncate px-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{resolvedTitle}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{view === "code" ? (
|
||||
<>
|
||||
<CodeFontSizeControl
|
||||
decreaseLabel={t({ zh: "减小字号", us: "Decrease font size" })}
|
||||
increaseLabel={t({ zh: "增大字号", us: "Increase font size" })}
|
||||
index={codeFontSizeIndex}
|
||||
onIndexChange={setCodeFontSizeIndex}
|
||||
/>
|
||||
<SandboxAction
|
||||
disabled={!source}
|
||||
label={t({ zh: "复制源码", us: "Copy source" })}
|
||||
onClick={() => void copySource()}
|
||||
>
|
||||
<Icon
|
||||
name={copied ? "lucide:check" : "lucide:copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</SandboxAction>
|
||||
</>
|
||||
) : (
|
||||
<SandboxAction
|
||||
label={t({ zh: "重置预览", us: "Reset preview" })}
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
>
|
||||
<Icon name="lucide:rotate-ccw" className="size-4" />
|
||||
</SandboxAction>
|
||||
)}
|
||||
<SandboxAction
|
||||
label={t({
|
||||
zh: fullscreen ? "退出全屏" : "全屏显示",
|
||||
us: fullscreen ? "Exit fullscreen" : "Enter fullscreen",
|
||||
})}
|
||||
pressed={fullscreen}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
<Icon
|
||||
name={fullscreen ? "lucide:minimize-2" : "lucide:maximize-2"}
|
||||
className="size-4"
|
||||
/>
|
||||
</SandboxAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "preview" ? (
|
||||
<SandboxFrame
|
||||
fill={fullscreen}
|
||||
locale={locale}
|
||||
reloadKey={reloadKey}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
|
||||
{previewError ? (
|
||||
<SandboxMessage tone="error">
|
||||
{previewError.message}
|
||||
</SandboxMessage>
|
||||
) : previewModule ? (
|
||||
React.createElement(previewModule.default, {
|
||||
key: reloadKey,
|
||||
locale,
|
||||
})
|
||||
) : (
|
||||
<SandboxMessage>
|
||||
{t({ zh: "正在加载预览…", us: "Loading preview…" })}
|
||||
</SandboxMessage>
|
||||
)}
|
||||
</SandboxErrorBoundary>
|
||||
</SandboxFrame>
|
||||
) : (
|
||||
<div
|
||||
className={`grid min-h-72 ${fullscreen ? "min-h-0 flex-1 overflow-hidden" : ""} ${filenames.length > 1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`}
|
||||
>
|
||||
{filenames.length > 1 ? (
|
||||
<nav
|
||||
className="flex overflow-x-auto border-b border-slate-200 bg-slate-50 p-2 md:flex-col md:border-r md:border-b-0 dark:border-slate-800 dark:bg-slate-900/40"
|
||||
aria-label={t({ zh: "示例文件", us: "Example files" })}
|
||||
>
|
||||
{filenames.map((filename) => (
|
||||
<button
|
||||
key={filename}
|
||||
type="button"
|
||||
className="rounded-md px-2 py-1.5 text-left font-mono text-xs whitespace-nowrap text-slate-500 hover:bg-slate-200/70 hover:text-slate-950 aria-pressed:bg-indigo-100 aria-pressed:text-indigo-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:aria-pressed:bg-indigo-950 dark:aria-pressed:text-indigo-300"
|
||||
aria-pressed={selectedFile === filename}
|
||||
onClick={() => setSelectedFile(filename)}
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<pre
|
||||
className="m-0 min-w-0 overflow-auto bg-slate-50 p-4 font-mono text-slate-800 dark:bg-slate-950 dark:text-slate-100"
|
||||
style={{
|
||||
fontSize: `${codeFontSizes[codeFontSizeIndex]}px`,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<code className="block min-w-max">
|
||||
{sourceError ? (
|
||||
sourceError.message
|
||||
) : highlightedSource ? (
|
||||
<HighlightedCode source={highlightedSource} />
|
||||
) : (
|
||||
(source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSandboxText(
|
||||
value: SandboxLocalizedText | undefined,
|
||||
locale: "en-US" | "zh-Hans",
|
||||
fallback: string
|
||||
) {
|
||||
if (typeof value === "string") return value
|
||||
return value?.[locale] ?? fallback
|
||||
}
|
||||
|
||||
function CodeFontSizeControl({
|
||||
decreaseLabel,
|
||||
increaseLabel,
|
||||
index,
|
||||
onIndexChange,
|
||||
}: {
|
||||
decreaseLabel: string
|
||||
increaseLabel: string
|
||||
index: number
|
||||
onIndexChange: React.Dispatch<React.SetStateAction<number>>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-8 items-center rounded-md border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
aria-label={`${codeFontSizes[index]}px`}
|
||||
>
|
||||
<SandboxAction
|
||||
disabled={index === 0}
|
||||
label={decreaseLabel}
|
||||
onClick={() => onIndexChange((value) => Math.max(0, value - 1))}
|
||||
>
|
||||
<Icon name="lucide:minus" className="size-3.5" />
|
||||
</SandboxAction>
|
||||
<span className="w-9 text-center font-mono text-[11px] text-slate-500 tabular-nums dark:text-slate-400">
|
||||
{codeFontSizes[index]}
|
||||
</span>
|
||||
<SandboxAction
|
||||
disabled={index === codeFontSizes.length - 1}
|
||||
label={increaseLabel}
|
||||
onClick={() =>
|
||||
onIndexChange((value) =>
|
||||
Math.min(codeFontSizes.length - 1, value + 1)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="lucide:plus" className="size-3.5" />
|
||||
</SandboxAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedCode({ source }: { source: HighlightedSource }) {
|
||||
return source.map((line, lineIndex) => (
|
||||
<React.Fragment key={line.offset}>
|
||||
<span className="sandbox-code-line">
|
||||
{line.tokens.map((token) => (
|
||||
<span
|
||||
key={token.offset}
|
||||
className="sandbox-code-token"
|
||||
style={
|
||||
{
|
||||
"--sandbox-token-dark": token.dark,
|
||||
"--sandbox-token-light": token.light,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{lineIndex < source.length - 1 ? "\n" : null}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
function SandboxTab({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
children: React.ReactNode
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:text-slate-950 aria-pressed:bg-white aria-pressed:text-slate-950 aria-pressed:shadow-sm dark:text-slate-400 dark:hover:text-slate-100 dark:aria-pressed:bg-slate-700 dark:aria-pressed:text-slate-100"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SandboxAction({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
pressed,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick: VoidFunction
|
||||
pressed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-950 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SandboxMessage({
|
||||
children,
|
||||
className,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
tone?: "error" | "neutral"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-72 items-center justify-center p-6 text-sm ${tone === "error" ? "text-red-600 dark:text-red-400" : "text-slate-500 dark:text-slate-400"} ${className ?? ""}`}
|
||||
role={tone === "error" ? "alert" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSource(source: SandboxSource) {
|
||||
if (typeof source === "string") return source
|
||||
const loaded = await source()
|
||||
return typeof loaded === "string" ? loaded : loaded.default
|
||||
}
|
||||
|
||||
function toError(value: unknown) {
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
function renderSandboxError(error: Error) {
|
||||
return <SandboxMessage tone="error">{error.message}</SandboxMessage>
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
import type { DocLocale } from "../../content/types"
|
||||
|
||||
export type SandboxLocalizedText = string | Readonly<Record<DocLocale, string>>
|
||||
|
||||
export type SandboxPreviewProps = {
|
||||
locale: DocLocale
|
||||
}
|
||||
|
||||
export type SandboxPreviewModule = {
|
||||
default: ComponentType<SandboxPreviewProps>
|
||||
}
|
||||
|
||||
export type SandboxSourceModule = {
|
||||
default: string
|
||||
}
|
||||
|
||||
export type SandboxSource =
|
||||
| string
|
||||
| (() => Promise<string | SandboxSourceModule>)
|
||||
|
||||
export type SandboxDefinition = {
|
||||
defaultFile?: string
|
||||
entry: () => Promise<SandboxPreviewModule>
|
||||
files: Readonly<Record<string, SandboxSource>>
|
||||
id: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function defineSandbox<const Definition extends SandboxDefinition>(
|
||||
definition: Definition
|
||||
) {
|
||||
return definition
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
- key: foundations
|
||||
order: 10
|
||||
label:
|
||||
zh-Hans: 基础能力
|
||||
en-US: Foundations
|
||||
|
||||
- key: application
|
||||
order: 20
|
||||
label:
|
||||
zh-Hans: 应用构建
|
||||
en-US: Application
|
||||
|
||||
- key: content
|
||||
order: 30
|
||||
label:
|
||||
zh-Hans: 内容与数据
|
||||
en-US: Content and data
|
||||
+183
-61
@@ -7,6 +7,7 @@ import {
|
||||
type DocLocale,
|
||||
type DocLocation,
|
||||
type DocPackage,
|
||||
type DocPackageGroup,
|
||||
type DocPage,
|
||||
type DocSection,
|
||||
type DocTocItem,
|
||||
@@ -19,71 +20,54 @@ type DocContentModule = {
|
||||
}
|
||||
|
||||
type PackageDefinition = {
|
||||
group: string
|
||||
key: string
|
||||
label: string
|
||||
description: Translation<string>
|
||||
description: Record<DocLocale, string>
|
||||
order: number
|
||||
}
|
||||
|
||||
type PackageGroupDefinition = {
|
||||
key: string
|
||||
label: Record<DocLocale, string>
|
||||
order: number
|
||||
}
|
||||
|
||||
type ParsedDocPage = DocPage & { packageKey: string }
|
||||
|
||||
const packageDefinitions: readonly PackageDefinition[] = [
|
||||
{
|
||||
key: "blocks",
|
||||
label: "Blocks",
|
||||
description: {
|
||||
zh: "构建应用外壳,并接入导航、通知、聊天和外观控制。",
|
||||
us: "Build an application shell and add navigation, notifications, chat, and appearance controls.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "i18n",
|
||||
label: "I18n",
|
||||
description: {
|
||||
zh: "接入应用自有词典、本地化运行时、开发工具和完整翻译工作流。",
|
||||
us: "Add application-owned catalogs, localized runtime rendering, development tools, and translation workflows.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lexical",
|
||||
label: "Lexical",
|
||||
description: {
|
||||
zh: "从根节点和内容区域开始,逐步构建支持自定义操作与媒体的富文本编辑器。",
|
||||
us: "Build a rich-text editor progressively from its root and content surface to custom actions and media.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "media",
|
||||
label: "Media",
|
||||
description: {
|
||||
zh: "通过存储 Adapter 构建媒体浏览、上传、目录管理和资源选择流程。",
|
||||
us: "Build media browsing, uploads, folder management, and asset selection with a storage adapter.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "Preferences",
|
||||
description: {
|
||||
zh: "使用 SSR 快照、持久化回调和可组合副作用管理类型安全且可扩展的应用偏好。",
|
||||
us: "Manage typed, extensible application preferences with SSR snapshots, persistence callbacks, and composable effects.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: "Search",
|
||||
description: {
|
||||
zh: "通过可替换的 Adapter、分组结果、分页和搜索历史构建应用级搜索体验。",
|
||||
us: "Build application search with a replaceable adapter, grouped results, pagination, and search history.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ui",
|
||||
label: "UI",
|
||||
description: {
|
||||
zh: "配置共享 UI 层,并学习组合组件、Hooks、图标和本地化词典。",
|
||||
us: "Set up the shared UI layer and learn how to compose its components, hooks, icons, and locale catalogs.",
|
||||
},
|
||||
},
|
||||
]
|
||||
const packageGroupModules = import.meta.glob<unknown>("./package-groups.yaml", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
})
|
||||
|
||||
const packageGroupDefinitions = Object.values(packageGroupModules)
|
||||
.flatMap(parsePackageGroupDefinitions)
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.order - right.order || left.key.localeCompare(right.key)
|
||||
)
|
||||
|
||||
const packageGroupsByKey = new Map(
|
||||
packageGroupDefinitions.map((group) => [group.key, group])
|
||||
)
|
||||
|
||||
const packageMetaModules = import.meta.glob<unknown>(
|
||||
"../../../packages/*/docs/meta.yaml",
|
||||
{ eager: true, import: "default" }
|
||||
)
|
||||
|
||||
const packageDefinitions = Object.entries(packageMetaModules)
|
||||
.map(([path, metadata]) => parsePackageDefinition(path, metadata))
|
||||
.toSorted((left, right) => {
|
||||
const leftGroup = getPackageGroupDefinition(left.group)
|
||||
const rightGroup = getPackageGroupDefinition(right.group)
|
||||
|
||||
return (
|
||||
leftGroup.order - rightGroup.order ||
|
||||
left.order - right.order ||
|
||||
left.key.localeCompare(right.key)
|
||||
)
|
||||
})
|
||||
|
||||
const docModuleLoaders = import.meta.glob<DocContentModule>(
|
||||
"../../../packages/*/docs/*/**/*.mdx",
|
||||
@@ -113,12 +97,21 @@ const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = {
|
||||
),
|
||||
}
|
||||
|
||||
const packageGroupsByLocale: Record<DocLocale, readonly DocPackageGroup[]> = {
|
||||
"zh-Hans": createDocPackageGroups("zh-Hans"),
|
||||
"en-US": createDocPackageGroups("en-US"),
|
||||
}
|
||||
|
||||
validateLocalizedPagePairs(packagesByLocale)
|
||||
|
||||
export function getDocPackages(locale: DocLocale = defaultDocLocale) {
|
||||
return packagesByLocale[locale]
|
||||
}
|
||||
|
||||
export function getDocPackageGroups(locale: DocLocale = defaultDocLocale) {
|
||||
return packageGroupsByLocale[locale]
|
||||
}
|
||||
|
||||
export function getDocHref(docPackage: DocPackage, page?: DocPage) {
|
||||
const prefix = getLocalePrefix(docPackage.locale)
|
||||
const target = page ?? getOverviewPage(docPackage)
|
||||
@@ -249,14 +242,119 @@ function createDocPackage(
|
||||
}
|
||||
|
||||
return {
|
||||
group: definition.group,
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: trans(locale, definition.description),
|
||||
description: definition.description[locale],
|
||||
locale,
|
||||
pages,
|
||||
}
|
||||
}
|
||||
|
||||
function createDocPackageGroups(locale: DocLocale): readonly DocPackageGroup[] {
|
||||
const packages = packagesByLocale[locale]
|
||||
|
||||
return packageGroupDefinitions.flatMap((definition) => {
|
||||
const groupPackages = packages.filter(
|
||||
(docPackage) => docPackage.group === definition.key
|
||||
)
|
||||
if (groupPackages.length === 0) return []
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label[locale],
|
||||
packages: groupPackages,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parsePackageGroupDefinitions(
|
||||
value: unknown
|
||||
): PackageGroupDefinition[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("Package documentation groups must be an array.")
|
||||
}
|
||||
|
||||
const groups = value.map((entry, index) => {
|
||||
const path = `package-groups.yaml[${index}]`
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(
|
||||
`Package documentation group must be an object: "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
const label = entry.label
|
||||
if (!isRecord(label)) {
|
||||
throw new Error(
|
||||
`Package documentation group requires a localized "label" object: "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
key: readMetaString(entry, "key", path),
|
||||
label: {
|
||||
"zh-Hans": readMetaString(label, "zh-Hans", path),
|
||||
"en-US": readMetaString(label, "en-US", path),
|
||||
},
|
||||
order: readMetaNumber(entry, "order", path),
|
||||
}
|
||||
})
|
||||
|
||||
const duplicate = groups.find(
|
||||
(group, index) =>
|
||||
groups.findIndex((item) => item.key === group.key) !== index
|
||||
)
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate package documentation group "${duplicate.key}".`)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function getPackageGroupDefinition(key: string) {
|
||||
const group = packageGroupsByKey.get(key)
|
||||
if (!group) {
|
||||
throw new Error(`Unknown package documentation group "${key}".`)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
function parsePackageDefinition(
|
||||
path: string,
|
||||
value: unknown
|
||||
): PackageDefinition {
|
||||
const match = path.match(/\/packages\/([^/]+)\/docs\/meta\.yaml$/)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid package documentation metadata path "${path}". Expected packages/<package>/docs/meta.yaml.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(
|
||||
`Package documentation metadata must be an object: "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
const description = value.description
|
||||
if (!isRecord(description)) {
|
||||
throw new Error(
|
||||
`Package documentation metadata requires a localized "description" object: "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
group: readMetaString(value, "group", path),
|
||||
key: match[1]!,
|
||||
label: readMetaString(value, "label", path),
|
||||
description: {
|
||||
"zh-Hans": readMetaString(description, "zh-Hans", path),
|
||||
"en-US": readMetaString(description, "en-US", path),
|
||||
},
|
||||
order: readMetaNumber(value, "order", path),
|
||||
}
|
||||
}
|
||||
|
||||
function parseDocPage(
|
||||
path: string,
|
||||
frontmatter: Record<string, unknown>,
|
||||
@@ -347,6 +445,30 @@ function readNumber(frontmatter: Record<string, unknown>, key: string) {
|
||||
throw new Error(`Documentation frontmatter requires a number "${key}".`)
|
||||
}
|
||||
|
||||
function readMetaString(
|
||||
metadata: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string
|
||||
) {
|
||||
const value = metadata[key]
|
||||
if (typeof value === "string" && value.trim()) return value
|
||||
throw new Error(
|
||||
`Package documentation metadata requires a string "${key}": "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
function readMetaNumber(
|
||||
metadata: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string
|
||||
) {
|
||||
const value = metadata[key]
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
throw new Error(
|
||||
`Package documentation metadata requires a number "${key}": "${path}".`
|
||||
)
|
||||
}
|
||||
|
||||
function readToc(value: unknown): readonly DocTocItem[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value)) {
|
||||
@@ -367,5 +489,5 @@ function readToc(value: unknown): readonly DocTocItem[] {
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type DocSearchEntry = {
|
||||
}
|
||||
|
||||
export type DocPackage = {
|
||||
group: string
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
@@ -50,6 +51,12 @@ export type DocPackage = {
|
||||
pages: readonly DocPage[]
|
||||
}
|
||||
|
||||
export type DocPackageGroup = {
|
||||
key: string
|
||||
label: string
|
||||
packages: readonly DocPackage[]
|
||||
}
|
||||
|
||||
export type DocLocation = {
|
||||
locale: DocLocale
|
||||
package: DocPackage
|
||||
|
||||
+27
-9
@@ -1,7 +1,12 @@
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/appearance/globals.css";
|
||||
@import "@workspace/media/globals.css";
|
||||
@import "@workspace/overlays/globals.css";
|
||||
@import "@workspace/page/globals.css";
|
||||
@import "@workspace/search/globals.css";
|
||||
|
||||
@source "../../packages/*/docs/**/*.{mdx,ts,tsx}";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
@@ -30,8 +35,8 @@
|
||||
--card-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--popover: var(--docs-background-light, var(--color-white));
|
||||
--popover-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--primary: var(--color-indigo-600);
|
||||
--primary-foreground: var(--color-white);
|
||||
--accent: var(--color-indigo-600);
|
||||
--accent-foreground: var(--color-white);
|
||||
--secondary: var(--docs-surface-light, var(--color-slate-100));
|
||||
--secondary-foreground: var(
|
||||
--docs-foreground-light,
|
||||
@@ -39,8 +44,11 @@
|
||||
);
|
||||
--muted: var(--docs-surface-light, var(--color-slate-100));
|
||||
--muted-foreground: var(--docs-muted-light, var(--color-slate-600));
|
||||
--accent: var(--docs-surface-light, var(--color-slate-100));
|
||||
--accent-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--highlight: var(--docs-surface-light, var(--color-slate-100));
|
||||
--highlight-foreground: var(
|
||||
--docs-foreground-light,
|
||||
var(--color-slate-950)
|
||||
);
|
||||
--border: var(--color-slate-200);
|
||||
--input: var(--color-slate-200);
|
||||
--ring: var(--color-indigo-500);
|
||||
@@ -56,14 +64,14 @@
|
||||
--card-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--popover: var(--docs-surface-dark, var(--color-slate-900));
|
||||
--popover-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--primary: var(--color-indigo-400);
|
||||
--primary-foreground: var(--color-slate-950);
|
||||
--accent: var(--color-indigo-400);
|
||||
--accent-foreground: var(--color-slate-950);
|
||||
--secondary: var(--color-slate-800);
|
||||
--secondary-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--muted: var(--color-slate-800);
|
||||
--muted-foreground: var(--docs-muted-dark, var(--color-slate-400));
|
||||
--accent: var(--color-slate-800);
|
||||
--accent-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--highlight: var(--color-slate-800);
|
||||
--highlight-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--border: var(--color-slate-800);
|
||||
--input: var(--color-slate-700);
|
||||
--ring: var(--color-indigo-400);
|
||||
@@ -74,7 +82,7 @@
|
||||
|
||||
body,
|
||||
#root {
|
||||
@apply min-h-dvh;
|
||||
@apply min-h-dvh min-w-80;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -85,3 +93,13 @@
|
||||
@apply bg-indigo-200 text-indigo-950 dark:bg-indigo-500/40 dark:text-indigo-50;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.sandbox-code-token {
|
||||
color: var(--sandbox-token-light);
|
||||
}
|
||||
|
||||
.dark .sandbox-code-token {
|
||||
color: var(--sandbox-token-dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
getDocHref,
|
||||
getDocPackages,
|
||||
getDocSectionLabel,
|
||||
getDocsHomeHref,
|
||||
resolveDocLocation,
|
||||
} from "../content/registry"
|
||||
import type { DocLocation, DocPage, DocTocItem } from "../content/types"
|
||||
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
|
||||
import { Actions, SiteFooter, SiteHeader } from "../components/layout"
|
||||
import { mdxComponents } from "../components/mdx"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
|
||||
@@ -113,17 +112,12 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<SiteHeader
|
||||
data-article-sticky-header
|
||||
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
|
||||
>
|
||||
<Container className="flex size-full items-center gap-4">
|
||||
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
|
||||
<SlashLine />
|
||||
<h1 className="truncate">{docLocation.package.label}</h1>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
</header>
|
||||
subleading={<h1 className="truncate">{docLocation.package.label}</h1>}
|
||||
actions={<Actions />}
|
||||
/>
|
||||
|
||||
<section
|
||||
data-article-sticky-header
|
||||
@@ -220,7 +214,7 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
data-slot="left"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r md:block dark:border-slate-800"
|
||||
>
|
||||
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
|
||||
<Sidebar current={docLocation} />
|
||||
@@ -248,11 +242,7 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Container className="flex h-full w-full items-center">
|
||||
Copyright © 2026
|
||||
</Container>
|
||||
</footer>
|
||||
<SiteFooter className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-42
@@ -1,60 +1,71 @@
|
||||
import { Actions, Container } from "../components/layout"
|
||||
import { getDocHref, getDocPackages } from "../content/registry"
|
||||
import {
|
||||
Actions,
|
||||
Container,
|
||||
SiteFooter,
|
||||
SiteHeader,
|
||||
} from "../components/layout"
|
||||
import { getDocHref, getDocPackageGroups } from "../content/registry"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
|
||||
export default function IndexPage() {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const docPackages = getDocPackages(locale)
|
||||
const packageGroups = getDocPackageGroups(locale)
|
||||
const packageNumbers = new Map(
|
||||
packageGroups
|
||||
.flatMap((group) => group.packages)
|
||||
.map((docPackage, index) => [docPackage.key, index + 1])
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className="flex items-center justify-between gap-4 pt-8 pb-10 sm:pt-12 sm:pb-14 lg:pb-16">
|
||||
<h1 className="min-w-0 text-2xl font-semibold sm:text-3xl">
|
||||
<span>S</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">imple</span>
|
||||
<span> </span>
|
||||
<span>R</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">eact</span>
|
||||
<span> </span>
|
||||
<span>A</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">pp</span>
|
||||
<span> </span>
|
||||
<span>K</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">it</span>
|
||||
</h1>
|
||||
<span className="text-xs align-bottom">alpha</span>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
<SiteHeader
|
||||
className="pt-8 pb-10 sm:pt-12 sm:pb-14 lg:pb-16"
|
||||
actions={<Actions />}
|
||||
/>
|
||||
<Container
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8"
|
||||
className="space-y-12 lg:space-y-16"
|
||||
aria-label={t({ zh: "文档导航", us: "Documentation" })}
|
||||
>
|
||||
{docPackages.map((docPackage, index) => (
|
||||
<a
|
||||
key={docPackage.key}
|
||||
href={getDocHref(docPackage)}
|
||||
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
|
||||
{packageGroups.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
aria-labelledby={`package-group-${group.key}`}
|
||||
>
|
||||
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{index > 9 ? `${index + 1}` : `0${index + 1}`}
|
||||
</div>
|
||||
<h2 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
|
||||
{docPackage.label}
|
||||
</h2>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{docPackage.description}
|
||||
</p>
|
||||
<h2
|
||||
id={`package-group-${group.key}`}
|
||||
className="mb-5 text-xl font-semibold text-slate-950 sm:mb-6 sm:text-2xl dark:text-slate-100"
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8">
|
||||
{group.packages.map((docPackage) => (
|
||||
<a
|
||||
key={docPackage.key}
|
||||
href={getDocHref(docPackage)}
|
||||
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
|
||||
>
|
||||
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{String(packageNumbers.get(docPackage.key)).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
|
||||
{docPackage.label}
|
||||
</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{docPackage.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
))}
|
||||
</Container>
|
||||
<footer className="py-8 sm:py-12">
|
||||
<Container className="text-center text-slate-500 dark:text-slate-400">
|
||||
<div>Copyright © 2026</div>
|
||||
</Container>
|
||||
</footer>
|
||||
<SiteFooter className="sm:py-12" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,7 +1,11 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { BackButton, Container, SlashLine } from "../components/layout"
|
||||
import {
|
||||
Container,
|
||||
SiteFooter,
|
||||
SiteHeader,
|
||||
} from "../components/layout"
|
||||
import { LocaleFlag } from "../components/locale-flag"
|
||||
import { SearchTrigger } from "../components/search-trigger"
|
||||
import { Swatch } from "../components/swatch"
|
||||
@@ -80,19 +84,16 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80">
|
||||
<Container className="flex h-16 items-center gap-4">
|
||||
<BackButton />
|
||||
<SlashLine />
|
||||
<SiteHeader
|
||||
subleading={
|
||||
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
|
||||
<div className="ml-auto">
|
||||
<SearchTrigger />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
}
|
||||
className="h-16 border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80"
|
||||
actions={<SearchTrigger />}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<Container className="py-8 sm:py-12">
|
||||
<Container className="pt-8 sm:pt-12">
|
||||
<section aria-labelledby="appearance-heading">
|
||||
<h2 id="appearance-heading" className="mb-4 text-xl font-semibold">
|
||||
{t(messages.appearance)}
|
||||
@@ -271,6 +272,7 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"include": [
|
||||
"src",
|
||||
"../packages/*/docs/sandboxes/**/*.ts",
|
||||
"../packages/*/docs/sandboxes/**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/components/sandbox/sandbox-env.d.ts"],
|
||||
"include": []
|
||||
}
|
||||
+43
-2
@@ -3,15 +3,17 @@ import babel from "@rolldown/plugin-babel"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { icones } from "@icones/vite"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import expressiveCode from "satteri-expressive-code"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig, type Plugin } from "vite"
|
||||
import { satteri } from "vite-plugin-satteri"
|
||||
import { parse } from "yaml"
|
||||
|
||||
const packagesDirectory = fileURLToPath(new URL("../packages", import.meta.url))
|
||||
|
||||
/**
|
||||
* Vite 默认监听 docs 根目录。现有的外部 MDX 会作为模块被监听,但新建 package
|
||||
* 或新建 MDX 文件在首次进入模块图之前不会触发 import.meta.glob 重新计算。
|
||||
* Vite 默认监听 docs 根目录。现有的外部文档与元数据会作为模块被监听,但新建
|
||||
* package、meta.yaml 或 MDX 文件在首次进入模块图之前不会触发 glob 重新计算。
|
||||
*/
|
||||
function watchPackageDocs(): Plugin {
|
||||
return {
|
||||
@@ -22,6 +24,27 @@ function watchPackageDocs(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function loadYamlModules(): Plugin {
|
||||
return {
|
||||
name: "load-yaml-modules",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
if (!/\.ya?ml(?:\?|$)/.test(id)) return
|
||||
|
||||
try {
|
||||
return {
|
||||
code: `export default ${JSON.stringify(parse(source))}`,
|
||||
map: null,
|
||||
}
|
||||
} catch (error) {
|
||||
this.error(
|
||||
`Unable to parse ${id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loadDocSearchSources(): Plugin {
|
||||
const suffix = "?docs-search-raw"
|
||||
const virtualPrefix = "\0docs-search-raw:"
|
||||
@@ -59,6 +82,7 @@ function loadDocSearchSources(): Plugin {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
watchPackageDocs(),
|
||||
loadYamlModules(),
|
||||
loadDocSearchSources(),
|
||||
satteri({
|
||||
features: {
|
||||
@@ -69,6 +93,23 @@ export default defineConfig({
|
||||
mdx: {
|
||||
jsxImportSource: "react",
|
||||
},
|
||||
hastPlugins: [
|
||||
expressiveCode({
|
||||
frames: false,
|
||||
textMarkers: false,
|
||||
themes: ["github-light", "github-dark"],
|
||||
useDarkModeMediaQuery: false,
|
||||
themeCssSelector: (theme) =>
|
||||
theme.type === "dark" ? ".dark" : ":root:not(.dark)",
|
||||
styleOverrides: {
|
||||
borderRadius: "0.75rem",
|
||||
borderWidth: "1px",
|
||||
codeFontSize: "0.875rem",
|
||||
codeLineHeight: "1.5rem",
|
||||
codePaddingInline: "1rem",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
icones({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Atomic composition
|
||||
description: Build a custom appearance surface from controlled components without the preset dialog.
|
||||
order: 1
|
||||
toc:
|
||||
- id: controlled-atoms
|
||||
title: Controlled atoms
|
||||
- id: compose-card
|
||||
title: Compose a theme card
|
||||
- id: connected-layer
|
||||
title: State bindings
|
||||
- id: live-preview
|
||||
title: Live preview
|
||||
---
|
||||
|
||||
## Controlled atoms {#controlled-atoms}
|
||||
|
||||
`ThemeModeField`, `ThemeModeOptionList`, `ColorVisionModeField`, `TextScaleField`, `TextScaleOptionList`, `MotionModeField`, `BrightnessField`, `ThemeColorPicker`, and `ThemeToggle` are driven by values and callbacks. They do not require preference context, so they can work with forms, preview tools, or another state library.
|
||||
|
||||
```tsx
|
||||
<ThemeModeField value={mode} onValueChange={setMode} />
|
||||
<ThemeModeOptionList value={mode} onValueChange={setMode} />
|
||||
<ColorVisionModeField value={colorVisionMode} onValueChange={setColorVisionMode} />
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
<TextScaleOptionList value={textScale} onValueChange={setTextScale} />
|
||||
```
|
||||
|
||||
`AppearanceOptionList` is the reusable radio list behind `TextScaleOptionList`. Give its `items` a `value`, `label`, and optional `icon` and `description` to build theme, motion, color-vision, or brightness selectors with the same presentation. It uses native radio inputs for keyboard navigation and form semantics.
|
||||
|
||||
## Compose a theme card {#compose-card}
|
||||
|
||||
`ThemeConfigCard` combines base and accent color controls while keeping the configuration owned by the caller.
|
||||
|
||||
```tsx
|
||||
<ThemeConfigCard
|
||||
config={{ baseColor, accentColor }}
|
||||
onConfigChange={setThemeColors}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
For a more granular layout, compose `baseColorSwatches`, `accentColorSwatches`, and `ThemeColorSwatchButton` directly.
|
||||
|
||||
`ThemeColorPicker` uses the labeled `pill` style by default. For tighter layouts, switch to the color-only `swatch` style with its selected indicator:
|
||||
|
||||
```tsx
|
||||
<ThemeColorPicker
|
||||
title="Accent color"
|
||||
swatches={accentColorSwatches}
|
||||
value={accentColor}
|
||||
variant="swatch"
|
||||
onValueChange={setAccentColor}
|
||||
/>
|
||||
```
|
||||
|
||||
## State bindings {#connected-layer}
|
||||
|
||||
`AppearanceSettings` is the default binding layer between the atoms and the shared `PreferencesProvider`. A custom surface can use Appearance hooks such as `useThemeMode`, `useThemeConfig`, and `useResolvedTheme` without knowing shared-preference string keys or reusing the default layout.
|
||||
|
||||
```tsx
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
## Live preview {#live-preview}
|
||||
|
||||
The documentation Sandbox can load a typed, multi-file example without embedding implementation details in MDX:
|
||||
|
||||
<Sandbox example="appearance/theme-colors" title="Theme color controls" />
|
||||
|
||||
For a single file, import the component and its raw source directly instead of creating a manifest:
|
||||
|
||||
```mdx
|
||||
import Demo from "./Demo"
|
||||
import source from "./Demo.tsx?raw"
|
||||
|
||||
<SingleFileSandbox component={Demo} filename="Demo.tsx" source={source} />
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Appearance settings dialog
|
||||
description: Compose a trigger, controller, and hotkeys into a complete appearance settings flow.
|
||||
order: 1
|
||||
toc:
|
||||
- id: controller
|
||||
title: Mount the controller
|
||||
- id: trigger
|
||||
title: Add a trigger
|
||||
- id: custom-handle
|
||||
title: Independent dialog instance
|
||||
---
|
||||
|
||||
## Mount the controller {#controller}
|
||||
|
||||
Mount one `AppearanceController` inside the application's shared `PreferencesProvider`. It composes the default settings content and registers `Mod+,` to open the dialog and `Mod+D` to toggle the theme.
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={preferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
<App />
|
||||
</PreferencesProvider>
|
||||
```
|
||||
|
||||
## Add a trigger {#trigger}
|
||||
|
||||
Place `AppearanceDialogTrigger` in a user menu, settings button, or command palette. By default it connects to the handle used by the default controller.
|
||||
|
||||
```tsx
|
||||
<AppearanceDialogTrigger render={<button type="button" />}>
|
||||
Appearance
|
||||
</AppearanceDialogTrigger>
|
||||
```
|
||||
|
||||
## Independent dialog instance {#custom-handle}
|
||||
|
||||
When a page needs an isolated appearance dialog, pass the same new handle to its trigger and controller.
|
||||
|
||||
```tsx
|
||||
const handle = createAppearanceDialogHandle()
|
||||
|
||||
<AppearanceDialogTrigger handle={handle} render={<button type="button" />}>
|
||||
Open theme settings
|
||||
</AppearanceDialogTrigger>
|
||||
<AppearanceController handle={handle} />
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Set up appearance preferences
|
||||
description: Aggregate appearance definitions and effects with the application's shared preferences.
|
||||
order: 1
|
||||
toc:
|
||||
- id: provider
|
||||
title: Aggregate preferences
|
||||
- id: effects
|
||||
title: Register effects
|
||||
- id: theme-style
|
||||
title: Inject theme variables
|
||||
- id: persistence
|
||||
title: Persist updates
|
||||
---
|
||||
|
||||
## Aggregate preferences {#provider}
|
||||
|
||||
Build one complete preference definition map at the application boundary. Spread definitions from appearance, locale, keyboard, and other feature packages into this map, then create or load the initial values.
|
||||
|
||||
Import Appearance after UI in the application stylesheet. No UI component changes are required:
|
||||
|
||||
```css
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/appearance/globals.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
...localePreferenceDefinitions,
|
||||
}
|
||||
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [
|
||||
appearancePreferenceEffect,
|
||||
localePreferenceEffect,
|
||||
] as const
|
||||
|
||||
export function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`defaultAppearancePreferences` remains available when only the appearance slice is needed, but the definition map is the preferred source when several packages are combined.
|
||||
|
||||
## Register effects {#effects}
|
||||
|
||||
`appearancePreferenceEffect` updates document classes, root attributes, and theme variables before paint, then watches operating-system theme and motion preferences after paint. Register it in the stable `effects` array passed to `PreferencesProvider`.
|
||||
|
||||
## Inject theme variables {#theme-style}
|
||||
|
||||
Use `buildThemeStyleSheet` to generate light and dark CSS variables from one base color, one accent color, and a color-vision mode. Keep the stable element ID so the appearance effect can update the same node when preferences change.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
;<style
|
||||
id={THEME_STYLE_ELEMENT_ID}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: buildThemeStyleSheet(
|
||||
{ baseColor, accentColor },
|
||||
colorVisionMode,
|
||||
brightness
|
||||
),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Assistance modes adjust chart and destructive semantic colors and set `data-ui-color-vision`. They are not color-vision simulators and do not transform photos, video, or brand assets.
|
||||
|
||||
Text scaling changes the root font size through `data-ui-text-scale`; reduced motion shortens global animation through `data-ui-motion` while preserving low-motion loading feedback; brightness adjusts theme surface variables without using `filter: brightness()`.
|
||||
|
||||
## Persist updates {#persistence}
|
||||
|
||||
The shared `onPreferenceChange` receives a typed `{ key, value }` only when a value actually changes. Forward it to a server function or cookie writer owned by the application.
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={preferences}
|
||||
onPreferenceChange={(update) => savePreference(update)}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Overview
|
||||
description: Understand the Appearance package atoms, theme data, and preference boundary.
|
||||
order: 1
|
||||
toc:
|
||||
- id: package-role
|
||||
title: Package role
|
||||
- id: atomic-components
|
||||
title: Atomic components
|
||||
- id: preferences
|
||||
title: Preference integration
|
||||
- id: hooks
|
||||
title: Hooks
|
||||
---
|
||||
|
||||
## Package role {#package-role}
|
||||
|
||||
`@workspace/appearance` owns theme mode, base color, accent color, color-vision assistance, text scale, motion, and interface brightness. It does not create its own provider or store, and it does not require changes to `@workspace/ui`. The application aggregates it with other preference packages through `@workspace/preferences`.
|
||||
|
||||
## Atomic components {#atomic-components}
|
||||
|
||||
The controlled components can be composed independently without preference context:
|
||||
|
||||
- `ThemeModeField` selects light, dark, or system mode.
|
||||
- `ColorVisionModeField` selects the standard, protan, deutan, or tritan assistance palette.
|
||||
- `TextScaleField` scales the interface to 100%, 125%, 150%, or 200%.
|
||||
- `MotionModeField` follows the system, reduces motion, or enables full motion.
|
||||
- `BrightnessField` selects dim, normal, or bright interface surfaces.
|
||||
- `ThemeColorPicker` and `ThemeColorSwatchButton` select theme colors.
|
||||
- `ThemeConfigCard` combines the base color and accent color controls.
|
||||
- `ThemeToggle` switches between light and dark themes.
|
||||
|
||||
`AppearanceSettings`, `AppearanceDialog`, and `AppearanceController` are optional composition layers built from these atoms.
|
||||
|
||||
## Preference integration {#preferences}
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
AppearanceController,
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
}
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [appearancePreferenceEffect] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Add definitions and effects from locale, keyboard, or other packages to the same objects. The application can persist every `{ key, value }` update through the shared `onPreferenceChange` and parse cookies on the server with the aggregated definitions.
|
||||
|
||||
Color-vision assistance does not filter the complete document or its images. It replaces semantic colors such as `destructive` and `chart-*`, and writes `data-ui-color-vision` to `<html>` so application components can add icons, labels, shapes, or patterns. Important information must never rely on color alone.
|
||||
|
||||
Text, motion, and brightness are also implemented entirely through root attributes and CSS variables. Once the Appearance stylesheet is imported, existing UI components, portals, and application content inherit these settings.
|
||||
|
||||
## Hooks {#hooks}
|
||||
|
||||
Applications do not need to know the string keys that Appearance uses in the shared store. The package provides a semantic hook for every preference: `useThemeMode`, `useAccentColor`, `useBaseColor`, `useColorVisionMode`, `useTextScale`, `useMotionMode`, and `useBrightness`.
|
||||
|
||||
Composition and resolution hooks support higher-level surfaces:
|
||||
|
||||
- `useThemeConfig` reads and updates base and accent colors as one configuration.
|
||||
- `useSystemTheme` and `useSystemMotion` only read operating-system settings.
|
||||
- `useResolvedTheme` and `useResolvedMotion` return the final value after resolving `system`.
|
||||
- `useToggleTheme` returns a callback that switches between explicit light and dark modes.
|
||||
- `useAppearancePreference` is the typed low-level entry point, while `useAppearancePreferences` returns the complete Appearance snapshot.
|
||||
|
||||
```tsx
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
group: foundations
|
||||
order: 20
|
||||
label: Appearance
|
||||
description:
|
||||
zh-Hans: 配置主题、颜色、文字缩放、动态效果与界面亮度。
|
||||
en-US: Configure themes, colors, text scaling, motion, and interface brightness.
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ThemeColorPicker,
|
||||
accentColorSwatches,
|
||||
type ThemeColorPickerVariant,
|
||||
} from "@workspace/appearance/components/theme-color-picker"
|
||||
import type { ThemeColorConfig } from "@workspace/appearance/theme"
|
||||
|
||||
export function AccentPicker({
|
||||
config,
|
||||
onConfigChange,
|
||||
title,
|
||||
variant,
|
||||
}: {
|
||||
config: ThemeColorConfig
|
||||
onConfigChange: (config: ThemeColorConfig) => void
|
||||
title: string
|
||||
variant?: ThemeColorPickerVariant
|
||||
}) {
|
||||
return (
|
||||
<ThemeColorPicker
|
||||
title={title}
|
||||
swatches={accentColorSwatches}
|
||||
value={config.accentColor}
|
||||
variant={variant}
|
||||
onValueChange={(accentColor) =>
|
||||
onConfigChange({ ...config, accentColor })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { messages as englishMessages } from "@workspace/appearance/locales/en-US"
|
||||
import { messages as chineseMessages } from "@workspace/appearance/locales/zh-Hans"
|
||||
import type { ThemeColorConfig } from "@workspace/appearance/theme"
|
||||
|
||||
import { AccentPicker } from "./AccentPicker"
|
||||
|
||||
const catalogs = {
|
||||
"en-US": englishMessages,
|
||||
"zh-Hans": chineseMessages,
|
||||
} as const
|
||||
|
||||
export default function ThemeColorsExample({
|
||||
locale,
|
||||
}: {
|
||||
locale: "en-US" | "zh-Hans"
|
||||
}) {
|
||||
const [config, setConfig] = React.useState<ThemeColorConfig>({
|
||||
accentColor: "indigo",
|
||||
baseColor: "neutral",
|
||||
})
|
||||
|
||||
const title = locale === "zh-Hans" ? "强调色" : "Accent color"
|
||||
|
||||
return (
|
||||
<I18nProvider catalogs={catalogs} locale={locale}>
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
||||
<AccentPicker
|
||||
config={config}
|
||||
title={title}
|
||||
onConfigChange={setConfig}
|
||||
/>
|
||||
<AccentPicker
|
||||
config={config}
|
||||
title={title}
|
||||
variant="swatch"
|
||||
onConfigChange={setConfig}
|
||||
/>
|
||||
</div>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: "appearance/theme-colors",
|
||||
title: {
|
||||
"en-US": "Controlled theme colors",
|
||||
"zh-Hans": "受控主题颜色",
|
||||
},
|
||||
entry: () => import("./App"),
|
||||
files: {
|
||||
"App.tsx": () => import("./App.tsx?raw"),
|
||||
"AccentPicker.tsx": () => import("./AccentPicker.tsx?raw"),
|
||||
},
|
||||
defaultFile: "App.tsx",
|
||||
} satisfies import("../../../../../docs/src/components/sandbox/types").SandboxDefinition
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../docs/tsconfig.sandbox.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../../docs/node_modules/.tmp/appearance-docs.tsbuildinfo"
|
||||
},
|
||||
"include": ["sandboxes/**/*.ts", "sandboxes/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: 原子化组合
|
||||
description: 不依赖预设弹窗,使用受控组件构建自己的外观界面。
|
||||
order: 1
|
||||
toc:
|
||||
- id: controlled-atoms
|
||||
title: 受控原子组件
|
||||
- id: compose-card
|
||||
title: 组合主题卡片
|
||||
- id: connected-layer
|
||||
title: 状态连接层
|
||||
- id: live-preview
|
||||
title: 在线预览
|
||||
---
|
||||
|
||||
## 受控原子组件 {#controlled-atoms}
|
||||
|
||||
`ThemeModeField`、`ThemeModeOptionList`、`ColorVisionModeField`、`TextScaleField`、`TextScaleOptionList`、`MotionModeField`、`BrightnessField`、`ThemeColorPicker` 和 `ThemeToggle` 都由 `value` 与回调驱动。它们不要求使用偏好上下文,因此可以接入表单、预览工具或其他状态库。
|
||||
|
||||
```tsx
|
||||
<ThemeModeField value={mode} onValueChange={setMode} />
|
||||
<ThemeModeOptionList value={mode} onValueChange={setMode} />
|
||||
<ColorVisionModeField value={colorVisionMode} onValueChange={setColorVisionMode} />
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
<TextScaleOptionList value={textScale} onValueChange={setTextScale} />
|
||||
```
|
||||
|
||||
`AppearanceOptionList` 提供 `TextScaleOptionList` 使用的通用单选列表。向 `items` 传入 `value`、`label`、可选 `icon` 与 `description`,即可用相同风格实现主题、动态效果、色彩辨识和亮度选择器。组件使用原生 radio,自动支持键盘导航与表单语义。
|
||||
|
||||
## 组合主题卡片 {#compose-card}
|
||||
|
||||
`ThemeConfigCard` 把基础色和强调色选择组合在一起,但仍由调用方持有配置。
|
||||
|
||||
```tsx
|
||||
<ThemeConfigCard
|
||||
config={{ baseColor, accentColor }}
|
||||
onConfigChange={setThemeColors}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
需要更细粒度的布局时,可以分别使用 `baseColorSwatches`、`accentColorSwatches` 和 `ThemeColorSwatchButton`。
|
||||
|
||||
`ThemeColorPicker` 默认使用带文字的 `pill` 样式;空间紧凑时可切换为仅显示颜色和选中标记的 `swatch` 样式:
|
||||
|
||||
```tsx
|
||||
<ThemeColorPicker
|
||||
title="强调色"
|
||||
swatches={accentColorSwatches}
|
||||
value={accentColor}
|
||||
variant="swatch"
|
||||
onValueChange={setAccentColor}
|
||||
/>
|
||||
```
|
||||
|
||||
## 状态连接层 {#connected-layer}
|
||||
|
||||
`AppearanceSettings` 是原子组件与共享 `PreferencesProvider` 之间的默认连接层。自定义界面可以使用 Appearance 提供的 `useThemeMode`、`useThemeConfig`、`useResolvedTheme` 等 hooks,而不必知道共享偏好的字符串键,也不必复用默认布局。
|
||||
|
||||
```tsx
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
## 在线预览 {#live-preview}
|
||||
|
||||
文档 Sandbox 可以加载通过类型检查的多文件示例,而不需要把实现细节内联到 MDX:
|
||||
|
||||
<Sandbox example="appearance/theme-colors" title="主题颜色控件" />
|
||||
|
||||
单文件示例不需要创建 Manifest,直接导入组件和原始源码即可:
|
||||
|
||||
```mdx
|
||||
import Demo from "./Demo"
|
||||
import source from "./Demo.tsx?raw"
|
||||
|
||||
<SingleFileSandbox component={Demo} filename="Demo.tsx" source={source} />
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: 外观设置弹窗
|
||||
description: 组合触发器、控制器和快捷键得到完整的外观设置流程。
|
||||
order: 1
|
||||
toc:
|
||||
- id: controller
|
||||
title: 挂载控制器
|
||||
- id: trigger
|
||||
title: 添加触发器
|
||||
- id: custom-handle
|
||||
title: 独立弹窗实例
|
||||
---
|
||||
|
||||
## 挂载控制器 {#controller}
|
||||
|
||||
在应用共享的 `PreferencesProvider` 内挂载一次 `AppearanceController`。它组合默认设置内容,并注册 `Mod+,` 打开弹窗和 `Mod+D` 切换主题。
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={preferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
<App />
|
||||
</PreferencesProvider>
|
||||
```
|
||||
|
||||
## 添加触发器 {#trigger}
|
||||
|
||||
`AppearanceDialogTrigger` 可以放在用户菜单、设置按钮或命令面板中。默认情况下,它会连接到默认控制器使用的 handle。
|
||||
|
||||
```tsx
|
||||
<AppearanceDialogTrigger render={<button type="button" />}>
|
||||
界面外观
|
||||
</AppearanceDialogTrigger>
|
||||
```
|
||||
|
||||
## 独立弹窗实例 {#custom-handle}
|
||||
|
||||
页面中需要隔离的外观弹窗时,为控制器和触发器传入同一个新 handle。
|
||||
|
||||
```tsx
|
||||
const handle = createAppearanceDialogHandle()
|
||||
|
||||
<AppearanceDialogTrigger handle={handle} render={<button type="button" />}>
|
||||
打开主题设置
|
||||
</AppearanceDialogTrigger>
|
||||
<AppearanceController handle={handle} />
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: 接入外观偏好
|
||||
description: 使用应用共享的偏好系统聚合外观定义与副作用。
|
||||
order: 1
|
||||
toc:
|
||||
- id: provider
|
||||
title: 聚合偏好
|
||||
- id: effects
|
||||
title: 注册副作用
|
||||
- id: theme-style
|
||||
title: 注入主题变量
|
||||
- id: persistence
|
||||
title: 持久化更新
|
||||
---
|
||||
|
||||
## 聚合偏好 {#provider}
|
||||
|
||||
在应用边界构建一份完整的偏好定义。把外观、语言、快捷键等功能 package 的 definitions 合并,再创建默认值或从服务端加载初始值。
|
||||
|
||||
在应用样式中于 UI 之后导入 Appearance,无需修改任何 UI 组件:
|
||||
|
||||
```css
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/appearance/globals.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
...localePreferenceDefinitions,
|
||||
}
|
||||
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [
|
||||
appearancePreferenceEffect,
|
||||
localePreferenceEffect,
|
||||
] as const
|
||||
|
||||
export function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
只需要外观偏好切片时仍可使用 `defaultAppearancePreferences`;聚合多个 package 时,应优先以 definitions 作为生成完整默认值的单一来源。
|
||||
|
||||
## 注册副作用 {#effects}
|
||||
|
||||
`appearancePreferenceEffect` 在浏览器绘制前更新文档类名、根节点属性和主题变量,并在绘制后监听操作系统主题与动态效果偏好。把组合后的 effect 放进传给 `PreferencesProvider` 的稳定 `effects` 数组中。
|
||||
|
||||
## 注入主题变量 {#theme-style}
|
||||
|
||||
使用 `buildThemeStyleSheet` 根据一组基础色、强调色以及色彩辨识模式生成浅色和深色 CSS 变量。保留固定的元素 ID 后,外观 effect 可以在偏好变化时更新同一个节点。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
;<style
|
||||
id={THEME_STYLE_ELEMENT_ID}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: buildThemeStyleSheet(
|
||||
{ baseColor, accentColor },
|
||||
colorVisionMode,
|
||||
brightness
|
||||
),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
辅助模式会调整需要区分的图表与破坏性语义色,并设置 `data-ui-color-vision`。它不是色觉模拟器,也不会改变照片、视频或品牌资产。
|
||||
|
||||
文字缩放通过 `data-ui-text-scale` 改变根字号;减少动态效果通过 `data-ui-motion` 缩短全局动画,并为加载状态保留低运动反馈;亮度只调整主题表面变量,不使用 `filter: brightness()`。
|
||||
|
||||
## 持久化更新 {#persistence}
|
||||
|
||||
共享的 `onPreferenceChange` 只在值实际变化时收到类型安全的 `{ key, value }`。应用可以把它交给服务端函数或 Cookie 写入器。
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={preferences}
|
||||
onPreferenceChange={(update) => savePreference(update)}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 概览
|
||||
description: 了解 Appearance 包的原子组件、主题数据和偏好边界。
|
||||
order: 1
|
||||
toc:
|
||||
- id: package-role
|
||||
title: 包的职责
|
||||
- id: atomic-components
|
||||
title: 原子组件
|
||||
- id: preferences
|
||||
title: 偏好集成
|
||||
- id: hooks
|
||||
title: Hooks
|
||||
---
|
||||
|
||||
## 包的职责 {#package-role}
|
||||
|
||||
`@workspace/appearance` 负责主题模式、基础色、强调色、色彩辨识、文字缩放、动态效果和界面亮度。它不创建自己的 Provider 或 store,也不要求修改 `@workspace/ui`;应用通过 `@workspace/preferences` 与其他偏好 package 统一聚合。
|
||||
|
||||
## 原子组件 {#atomic-components}
|
||||
|
||||
受控组件可以独立组合,不依赖偏好上下文:
|
||||
|
||||
- `ThemeModeField` 选择浅色、深色或跟随系统。
|
||||
- `ColorVisionModeField` 选择标准、Protan、Deutan 或 Tritan 辅助色板。
|
||||
- `TextScaleField` 将界面缩放到 100%、125%、150% 或 200%。
|
||||
- `MotionModeField` 跟随系统、减少动态效果或启用完整动效。
|
||||
- `BrightnessField` 选择柔暗、标准或明亮的界面表面。
|
||||
- `ThemeColorPicker` 与 `ThemeColorSwatchButton` 选择主题颜色。
|
||||
- `ThemeConfigCard` 组合基础色与强调色设置。
|
||||
- `ThemeToggle` 在浅色和深色主题之间切换。
|
||||
|
||||
`AppearanceSettings`、`AppearanceDialog` 与 `AppearanceController` 是建立在这些原子组件之上的可选组合层。
|
||||
|
||||
## 偏好集成 {#preferences}
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
AppearanceController,
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
}
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [appearancePreferenceEffect] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
把语言、快捷键等 package 的 definitions 和 effects 添加到相同对象即可。应用可以通过共享的 `onPreferenceChange` 持久化所有 `{ key, value }` 更新,并在服务端使用聚合后的 definitions 解析 Cookie。
|
||||
|
||||
色彩辨识辅助不会给整个页面或图片添加滤镜。它只替换 `destructive` 与 `chart-*` 等语义颜色,并在 `<html>` 上写入 `data-ui-color-vision`,供业务组件添加图标、文字、形状或纹理。关键信息不能只靠颜色表达。
|
||||
|
||||
文字、动态效果和亮度同样只通过根节点属性与 CSS 变量实现。导入 Appearance 样式后,现有 UI 组件、Portal 和应用内容都会继承这些设置。
|
||||
|
||||
## Hooks {#hooks}
|
||||
|
||||
应用不需要知道 Appearance 在共享 store 中使用的字符串键。Package 为每个偏好提供了语义化 hook:`useThemeMode`、`useAccentColor`、`useBaseColor`、`useColorVisionMode`、`useTextScale`、`useMotionMode` 和 `useBrightness`。
|
||||
|
||||
组合和解析类 hook 用于更高层的界面:
|
||||
|
||||
- `useThemeConfig` 将基础色和强调色作为一个配置读写。
|
||||
- `useSystemTheme` 与 `useSystemMotion` 只读取操作系统设置。
|
||||
- `useResolvedTheme` 与 `useResolvedMotion` 解析 `system` 后返回最终值。
|
||||
- `useToggleTheme` 返回在显式浅色与深色之间切换的回调。
|
||||
- `useAppearancePreference` 提供类型安全的底层入口,`useAppearancePreferences` 返回 Appearance 所有偏好的快照。
|
||||
|
||||
```tsx
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@workspace/appearance",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./color-vision": "./src/color-vision.ts",
|
||||
"./components/appearance-option-list": "./src/components/option-lists/appearance-option-list.tsx",
|
||||
"./components/brightness-field": "./src/components/fields/brightness-field.tsx",
|
||||
"./components/color-vision-mode-field": "./src/components/fields/color-vision-mode-field.tsx",
|
||||
"./components/motion-mode-field": "./src/components/fields/motion-mode-field.tsx",
|
||||
"./components/text-scale-field": "./src/components/fields/text-scale-field.tsx",
|
||||
"./components/text-scale-option-list": "./src/components/option-lists/text-scale-option-list.tsx",
|
||||
"./components/theme-color-picker": "./src/components/theme/theme-color-picker.tsx",
|
||||
"./components/theme-config-card": "./src/components/theme/theme-config-card.tsx",
|
||||
"./components/theme-mode-field": "./src/components/fields/theme-mode-field.tsx",
|
||||
"./components/theme-mode-option-list": "./src/components/option-lists/theme-mode-option-list.tsx",
|
||||
"./components/theme-toggle-button": "./src/components/theme/theme-toggle-button.tsx",
|
||||
"./components/fields": "./src/components/fields/index.ts",
|
||||
"./components/fields/*": "./src/components/fields/*.tsx",
|
||||
"./components/option-lists": "./src/components/option-lists/index.ts",
|
||||
"./components/option-lists/*": "./src/components/option-lists/*.tsx",
|
||||
"./components/theme": "./src/components/theme/index.ts",
|
||||
"./components/theme/*": "./src/components/theme/*.tsx",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./display": "./src/display.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./locales": "./src/locales/catalogs.ts",
|
||||
"./locales/*": "./src/locales/*.ts",
|
||||
"./preferences": "./src/preferences.ts",
|
||||
"./theme": "./src/theme.ts",
|
||||
"./use-resolved-theme": "./src/hooks/use-resolved-theme.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
/**
|
||||
* Color-vision assistance modes.
|
||||
*
|
||||
* These modes select palettes that are easier to distinguish. They are not
|
||||
* simulations of how another person sees the interface and intentionally do
|
||||
* not apply a filter to images or the complete document.
|
||||
*/
|
||||
export type ColorVisionMode = "standard" | "protan" | "deutan" | "tritan"
|
||||
|
||||
export const colorVisionModes = [
|
||||
"standard",
|
||||
"protan",
|
||||
"deutan",
|
||||
"tritan",
|
||||
] as const satisfies readonly ColorVisionMode[]
|
||||
|
||||
export const COLOR_VISION_ATTRIBUTE = "data-ui-color-vision"
|
||||
|
||||
type ColorVisionVariables = Readonly<
|
||||
Record<
|
||||
"destructive" | "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5",
|
||||
string
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Palettes for semantic data colors that commonly need to be distinguished.
|
||||
* Components must still pair color with text, icons, shapes, or patterns.
|
||||
*/
|
||||
export const colorVisionPalettes: Readonly<
|
||||
Record<
|
||||
Exclude<ColorVisionMode, "standard">,
|
||||
Record<ThemeScheme, ColorVisionVariables>
|
||||
>
|
||||
> = {
|
||||
protan: {
|
||||
light: {
|
||||
destructive: "#b54a00",
|
||||
"chart-1": "#0072b2",
|
||||
"chart-2": "#e69f00",
|
||||
"chart-3": "#009e73",
|
||||
"chart-4": "#cc79a7",
|
||||
"chart-5": "#6b5ca5",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#ff9d66",
|
||||
"chart-1": "#56b4e9",
|
||||
"chart-2": "#f0e442",
|
||||
"chart-3": "#65d5b3",
|
||||
"chart-4": "#e8a4ce",
|
||||
"chart-5": "#afa4e8",
|
||||
},
|
||||
},
|
||||
deutan: {
|
||||
light: {
|
||||
destructive: "#a64b00",
|
||||
"chart-1": "#005f9e",
|
||||
"chart-2": "#d88c00",
|
||||
"chart-3": "#8a6f00",
|
||||
"chart-4": "#bd63a0",
|
||||
"chart-5": "#168c9e",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#ff9d66",
|
||||
"chart-1": "#65bff0",
|
||||
"chart-2": "#f3c969",
|
||||
"chart-3": "#d7c85d",
|
||||
"chart-4": "#e5a0cf",
|
||||
"chart-5": "#63c7d5",
|
||||
},
|
||||
},
|
||||
tritan: {
|
||||
light: {
|
||||
destructive: "#b83261",
|
||||
"chart-1": "#b83280",
|
||||
"chart-2": "#008f72",
|
||||
"chart-3": "#d55e00",
|
||||
"chart-4": "#6b5ca5",
|
||||
"chart-5": "#53606d",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#f27ca5",
|
||||
"chart-1": "#ed8bc9",
|
||||
"chart-2": "#62d1b3",
|
||||
"chart-3": "#ff9d66",
|
||||
"chart-4": "#afa4e8",
|
||||
"chart-5": "#b8c2cc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function isColorVisionMode(value: unknown): value is ColorVisionMode {
|
||||
return colorVisionModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function getColorVisionCssVariables(
|
||||
mode: ColorVisionMode,
|
||||
scheme: ThemeScheme
|
||||
): ColorVisionVariables | undefined {
|
||||
return mode === "standard" ? undefined : colorVisionPalettes[mode][scheme]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { messages as englishMessages } from "../locales/en-US"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { TextScaleOptionList } from "./option-lists/text-scale-option-list"
|
||||
import { ThemeModeOptionList } from "./option-lists/theme-mode-option-list"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme/theme-color-picker"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderLocalized(node: React.ReactNode) {
|
||||
return render(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
{node}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function selectOption(triggerName: string, optionName: string) {
|
||||
fireEvent.click(screen.getByRole("combobox", { name: triggerName }))
|
||||
const option = screen.getByRole("option", { name: optionName })
|
||||
fireEvent.pointerDown(option, { pointerType: "mouse" })
|
||||
fireEvent.click(option, { detail: 1 })
|
||||
}
|
||||
|
||||
describe("appearance atoms", () => {
|
||||
it("reports color-vision mode changes without owning state", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ColorVisionModeField value="standard" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
selectOption("Color-vision assistance", "Green-weak assistance (deutan)")
|
||||
expect(onValueChange).toHaveBeenCalledWith("deutan")
|
||||
})
|
||||
|
||||
it("reports text scale, motion, and brightness changes", () => {
|
||||
const setTextScale = vi.fn()
|
||||
const setMotionMode = vi.fn()
|
||||
const setBrightness = vi.fn()
|
||||
|
||||
const { rerender } = renderLocalized(
|
||||
<TextScaleField value="100" onValueChange={setTextScale} />
|
||||
)
|
||||
selectOption("Text size", "150%")
|
||||
expect(setTextScale).toHaveBeenCalledWith("150")
|
||||
|
||||
rerender(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
<MotionModeField value="system" onValueChange={setMotionMode} />
|
||||
</I18nProvider>
|
||||
)
|
||||
selectOption("Motion", "Reduce motion")
|
||||
expect(setMotionMode).toHaveBeenCalledWith("reduce")
|
||||
|
||||
rerender(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
<BrightnessField value="normal" onValueChange={setBrightness} />
|
||||
</I18nProvider>
|
||||
)
|
||||
selectOption("Brightness", "Dim")
|
||||
expect(setBrightness).toHaveBeenCalledWith("dim")
|
||||
})
|
||||
|
||||
it("provides a radio-based text scale option list", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<TextScaleOptionList value="125" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole("group", { name: "Text size" })).not.toBeNull()
|
||||
expect(
|
||||
(screen.getByRole("radio", { name: "125%" }) as HTMLInputElement).checked
|
||||
).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "200%" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("200")
|
||||
})
|
||||
|
||||
it("provides a radio-based theme mode option list", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeModeOptionList value="system" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole("group", { name: "Theme mode" })).not.toBeNull()
|
||||
expect(
|
||||
(screen.getByRole("radio", { name: "System" }) as HTMLInputElement)
|
||||
.checked
|
||||
).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Dark" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("dark")
|
||||
})
|
||||
|
||||
it("reports the selected theme color", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeColorPicker
|
||||
onValueChange={onValueChange}
|
||||
swatches={accentColorSwatches}
|
||||
title="Primary color"
|
||||
value="blue"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("rose")
|
||||
})
|
||||
|
||||
it("renders compact color swatches without visible labels", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeColorPicker
|
||||
onValueChange={onValueChange}
|
||||
swatches={accentColorSwatches}
|
||||
title="Accent color"
|
||||
value="blue"
|
||||
variant="swatch"
|
||||
/>
|
||||
)
|
||||
|
||||
const blue = screen.getByRole("button", { name: "Blue" })
|
||||
const rose = screen.getByRole("button", { name: "Rose" })
|
||||
expect(blue.getAttribute("data-variant")).toBe("swatch")
|
||||
expect(blue.getAttribute("aria-pressed")).toBe("true")
|
||||
expect(rose.textContent).toBe("")
|
||||
|
||||
fireEvent.click(rose)
|
||||
expect(onValueChange).toHaveBeenCalledWith("rose")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import { useToggleTheme } from "../hooks/use-toggle-theme"
|
||||
import {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
appearanceDialogHandle,
|
||||
type AppearanceDialogHandle,
|
||||
} from "./appearance-dialog"
|
||||
import { AppearanceHotkeys } from "./appearance-hotkeys"
|
||||
|
||||
export interface AppearanceControllerProps {
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceController({
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceControllerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
const toggleDialog = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handle.close()
|
||||
} else {
|
||||
handle.open(null)
|
||||
}
|
||||
}, [handle, isOpen])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<AppearanceDialog handle={handle} onOpenChange={handleOpenChange}>
|
||||
<AppearanceHotkeys
|
||||
onToggleDialog={toggleDialog}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<AppearanceDialogContent />
|
||||
</AppearanceDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import {
|
||||
createDialogHandle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
type DialogHandle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import { AppearanceSettings } from "./appearance-settings"
|
||||
|
||||
export type AppearanceDialogHandle = DialogHandle<void>
|
||||
|
||||
export function createAppearanceDialogHandle(): AppearanceDialogHandle {
|
||||
return createDialogHandle<void>()
|
||||
}
|
||||
|
||||
export const appearanceDialogHandle = createAppearanceDialogHandle()
|
||||
|
||||
export interface AppearanceDialogProps {
|
||||
children?: ReactNode
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceDialog({
|
||||
children,
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceDialogProps) {
|
||||
return (
|
||||
<Dialog handle={handle} onOpenChange={onOpenChange}>
|
||||
{children ?? <AppearanceDialogContent />}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceDialogHeader() {
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<LocalizedText message={appearanceMessages.appearance} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<LocalizedText message={appearanceMessages.appearanceDescription} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogContentProps extends Omit<
|
||||
ComponentProps<typeof DialogContent>,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function AppearanceDialogContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AppearanceDialogContentProps) {
|
||||
return (
|
||||
<DialogContent className={cn("sm:max-w-max", className)} {...props}>
|
||||
<AppearanceDialogHeader />
|
||||
<div className="-mx-6 max-h-[75vh] overflow-y-auto px-6">
|
||||
{children ?? <AppearanceSettings />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogTriggerProps extends Omit<
|
||||
ComponentProps<typeof DialogTrigger>,
|
||||
"handle"
|
||||
> {
|
||||
handle?: AppearanceDialogHandle
|
||||
}
|
||||
|
||||
export function AppearanceDialogTrigger({
|
||||
handle = appearanceDialogHandle,
|
||||
...props
|
||||
}: AppearanceDialogTriggerProps) {
|
||||
return <DialogTrigger handle={handle} {...props} />
|
||||
}
|
||||
|
||||
export const AppearanceTrigger = AppearanceDialogTrigger
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
|
||||
export interface AppearanceHotkeysProps {
|
||||
onToggleDialog: VoidFunction
|
||||
onToggleTheme: VoidFunction
|
||||
}
|
||||
|
||||
export function AppearanceHotkeys({
|
||||
onToggleDialog,
|
||||
onToggleTheme,
|
||||
}: AppearanceHotkeysProps) {
|
||||
const appearanceTitle = useMessage(appearanceMessages.appearance)
|
||||
const appearanceDescription = useMessage(
|
||||
appearanceMessages.appearanceCommandDescription
|
||||
)
|
||||
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
|
||||
const toggleThemeDescription = useMessage(
|
||||
appearanceMessages.toggleThemeDescription
|
||||
)
|
||||
|
||||
useHotkey("Mod+,", onToggleDialog, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: appearanceTitle,
|
||||
description: appearanceDescription,
|
||||
},
|
||||
})
|
||||
|
||||
useHotkey("Mod+D", onToggleTheme, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: toggleThemeTitle,
|
||||
description: toggleThemeDescription,
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import {
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "../hooks/use-appearance-preference"
|
||||
import { useResolvedTheme } from "../hooks/use-resolved-theme"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { ThemeModeField } from "./fields/theme-mode-field"
|
||||
import { ThemeConfigCard } from "./theme/theme-config-card"
|
||||
|
||||
export interface AppearanceSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const [brightness, setBrightness] = useBrightness()
|
||||
const [colorVisionMode, setColorVisionMode] = useColorVisionMode()
|
||||
const [motionMode, setMotionMode] = useMotionMode()
|
||||
const [textScale, setTextScale] = useTextScale()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-10", className)}>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ThemeModeField value={themeMode} onValueChange={setThemeMode} />
|
||||
<ColorVisionModeField
|
||||
value={colorVisionMode}
|
||||
onValueChange={setColorVisionMode}
|
||||
/>
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
</div>
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { SunIcon } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { Brightness } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface BrightnessFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: Brightness) => void
|
||||
value: Brightness
|
||||
}
|
||||
|
||||
export function BrightnessField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: BrightnessFieldProps) {
|
||||
const title = useMessage(appearanceMessages.brightness)
|
||||
const dim = useMessage(appearanceMessages.brightnessDim)
|
||||
const normal = useMessage(appearanceMessages.brightnessNormal)
|
||||
const bright = useMessage(appearanceMessages.brightnessBright)
|
||||
const description = useMessage(appearanceMessages.brightnessDescription)
|
||||
const items: ReadonlyArray<{ label: string; value: Brightness }> = [
|
||||
{ label: dim, value: "dim" },
|
||||
{ label: normal, value: "normal" },
|
||||
{ label: bright, value: "bright" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(brightness) => {
|
||||
if (brightness) onValueChange(brightness)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<SunIcon />
|
||||
<SelectValue>
|
||||
{(selectedValue: Brightness | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { EyeIcon } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { ColorVisionMode } from "../../color-vision"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface ColorVisionModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: ColorVisionMode) => void
|
||||
value: ColorVisionMode
|
||||
}
|
||||
|
||||
export function ColorVisionModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ColorVisionModeFieldProps) {
|
||||
const title = useMessage(appearanceMessages.colorVisionMode)
|
||||
const standard = useMessage(appearanceMessages.colorVisionStandard)
|
||||
const protan = useMessage(appearanceMessages.colorVisionProtan)
|
||||
const deutan = useMessage(appearanceMessages.colorVisionDeutan)
|
||||
const tritan = useMessage(appearanceMessages.colorVisionTritan)
|
||||
const standardDescription = useMessage(
|
||||
appearanceMessages.colorVisionStandardDescription
|
||||
)
|
||||
const assistedDescription = useMessage(
|
||||
appearanceMessages.colorVisionAssistedDescription
|
||||
)
|
||||
const items: ReadonlyArray<{ label: string; value: ColorVisionMode }> = [
|
||||
{ label: standard, value: "standard" },
|
||||
{ label: protan, value: "protan" },
|
||||
{ label: deutan, value: "deutan" },
|
||||
{ label: tritan, value: "tritan" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{value === "standard" ? standardDescription : assistedDescription}
|
||||
</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
|
||||
<EyeIcon />
|
||||
<SelectValue>
|
||||
{(selectedValue: ColorVisionMode | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { BrightnessField, type BrightnessFieldProps } from "./brightness-field"
|
||||
export {
|
||||
ColorVisionModeField,
|
||||
type ColorVisionModeFieldProps,
|
||||
} from "./color-vision-mode-field"
|
||||
export { MotionModeField, type MotionModeFieldProps } from "./motion-mode-field"
|
||||
export { TextScaleField, type TextScaleFieldProps } from "./text-scale-field"
|
||||
export {
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./theme-mode-field"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { MotionMode } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface MotionModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: MotionMode) => void
|
||||
value: MotionMode
|
||||
}
|
||||
|
||||
export function MotionModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: MotionModeFieldProps) {
|
||||
const title = useMessage(appearanceMessages.motionMode)
|
||||
const system = useMessage(appearanceMessages.motionSystem)
|
||||
const reduce = useMessage(appearanceMessages.motionReduce)
|
||||
const full = useMessage(appearanceMessages.motionFull)
|
||||
const descriptions = {
|
||||
full: useMessage(appearanceMessages.motionFullDescription),
|
||||
reduce: useMessage(appearanceMessages.motionReduceDescription),
|
||||
system: useMessage(appearanceMessages.motionSystemDescription),
|
||||
}
|
||||
const items: ReadonlyArray<{ label: string; value: MotionMode }> = [
|
||||
{ label: system, value: "system" },
|
||||
{ label: reduce, value: "reduce" },
|
||||
{ label: full, value: "full" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{descriptions[value]}
|
||||
</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
|
||||
<SelectValue>
|
||||
{(selectedValue: MotionMode | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { ALargeSmall } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface TextScaleFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: TextScale) => void
|
||||
value: TextScale
|
||||
}
|
||||
|
||||
export function TextScaleField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: TextScaleFieldProps) {
|
||||
const title = useMessage(appearanceMessages.textScale)
|
||||
const description = useMessage(appearanceMessages.textScaleDescription)
|
||||
const items: ReadonlyArray<{ label: string; value: TextScale }> = [
|
||||
{ label: "100%", value: "100" },
|
||||
{ label: "125%", value: "125" },
|
||||
{ label: "150%", value: "150" },
|
||||
{ label: "200%", value: "200" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(scale) => {
|
||||
if (scale) onValueChange(scale)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<ALargeSmall />
|
||||
<SelectValue>
|
||||
{(selectedValue: TextScale | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
|
||||
export interface ThemeModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: ThemeMode) => void
|
||||
value: ThemeMode
|
||||
}
|
||||
|
||||
export function ThemeModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
if (mode === "light") return <SunIcon />
|
||||
if (mode === "dark") return <MoonIcon />
|
||||
return <MonitorIcon />
|
||||
}
|
||||
|
||||
export function ThemeModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ThemeModeFieldProps) {
|
||||
const lightLabel = useMessage(appearanceMessages.lightMode)
|
||||
const darkLabel = useMessage(appearanceMessages.darkMode)
|
||||
const systemLabel = useMessage(appearanceMessages.followSystem)
|
||||
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
|
||||
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
|
||||
const systemDescription = useMessage(
|
||||
appearanceMessages.followSystemDescription
|
||||
)
|
||||
const title = useMessage(appearanceMessages.themeMode)
|
||||
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
|
||||
{ label: lightLabel, value: "light" },
|
||||
{ label: darkLabel, value: "dark" },
|
||||
{ label: systemLabel, value: "system" },
|
||||
]
|
||||
const description = {
|
||||
light: lightDescription,
|
||||
dark: darkDescription,
|
||||
system: systemDescription,
|
||||
}[value]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={modeItems}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<SelectValue>
|
||||
{(selectedValue: ThemeMode | null) => (
|
||||
<>
|
||||
<ThemeModeIcon mode={selectedValue} />
|
||||
{modeItems.find((item) => item.value === selectedValue)?.label}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{modeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<ThemeModeIcon mode={item.value} />
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
export interface AppearanceOption<TValue extends string> {
|
||||
description?: ReactNode
|
||||
disabled?: boolean
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
value: TValue
|
||||
}
|
||||
|
||||
export interface AppearanceOptionListProps<TValue extends string> extends Omit<
|
||||
ComponentProps<"fieldset">,
|
||||
"onChange"
|
||||
> {
|
||||
items: readonly AppearanceOption<TValue>[]
|
||||
label: ReactNode
|
||||
name?: string
|
||||
onValueChange: (value: TValue) => void
|
||||
value: TValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, accessible option list shared by appearance preferences.
|
||||
* Native radio inputs provide keyboard navigation and form semantics while the
|
||||
* labels supply the larger visual target.
|
||||
*/
|
||||
export function AppearanceOptionList<TValue extends string>({
|
||||
className,
|
||||
disabled,
|
||||
items,
|
||||
label,
|
||||
name,
|
||||
onValueChange,
|
||||
value,
|
||||
...props
|
||||
}: AppearanceOptionListProps<TValue>) {
|
||||
const generatedName = useId()
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="appearance-option-list"
|
||||
className={cn(
|
||||
"min-w-0 space-y-1 rounded-xl border p-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
<legend className="sr-only">{label}</legend>
|
||||
{items.map((item) => {
|
||||
const checked = item.value === value
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.value}
|
||||
data-checked={checked}
|
||||
data-disabled={item.disabled || undefined}
|
||||
className={cn(
|
||||
"relative grid min-h-12 cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 py-2 transition-colors outline-none",
|
||||
"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
|
||||
checked
|
||||
? "bg-accent/12 text-accent dark:bg-accent/20"
|
||||
: "text-foreground hover:bg-highlight hover:text-highlight-foreground",
|
||||
item.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="peer sr-only"
|
||||
checked={checked}
|
||||
disabled={item.disabled}
|
||||
name={name ?? generatedName}
|
||||
value={item.value}
|
||||
onChange={() => onValueChange(item.value)}
|
||||
/>
|
||||
<span
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center [&_svg]:size-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description == null ? null : (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 block text-xs",
|
||||
checked ? "text-accent/80" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-opacity",
|
||||
checked ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
} from "./appearance-option-list"
|
||||
export {
|
||||
TextScaleOptionList,
|
||||
type TextScaleOptionListProps,
|
||||
} from "./text-scale-option-list"
|
||||
export {
|
||||
ThemeModeOptionList,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./theme-mode-option-list"
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
} from "./appearance-option-list"
|
||||
|
||||
const textScaleItems = [
|
||||
{
|
||||
icon: <span className="text-xs font-semibold">A</span>,
|
||||
label: "100%",
|
||||
value: "100",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-sm font-semibold">A</span>,
|
||||
label: "125%",
|
||||
value: "125",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-base font-semibold">A</span>,
|
||||
label: "150%",
|
||||
value: "150",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-lg font-semibold">A</span>,
|
||||
label: "200%",
|
||||
value: "200",
|
||||
},
|
||||
] as const satisfies readonly AppearanceOption<TextScale>[]
|
||||
|
||||
export interface TextScaleOptionListProps {
|
||||
className?: string
|
||||
onValueChange: (value: TextScale) => void
|
||||
value: TextScale
|
||||
}
|
||||
|
||||
export function TextScaleOptionList({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: TextScaleOptionListProps) {
|
||||
const title = useMessage(appearanceMessages.textScale)
|
||||
const description = useMessage(appearanceMessages.textScaleDescription)
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<AppearanceOptionList
|
||||
className="mt-2 w-full max-w-64"
|
||||
items={textScaleItems}
|
||||
label={title}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
} from "./appearance-option-list"
|
||||
|
||||
export interface ThemeModeOptionListProps {
|
||||
className?: string
|
||||
onValueChange: (value: ThemeMode) => void
|
||||
value: ThemeMode
|
||||
}
|
||||
|
||||
export function ThemeModeOptionList({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ThemeModeOptionListProps) {
|
||||
const title = useMessage(appearanceMessages.themeMode)
|
||||
const lightLabel = useMessage(appearanceMessages.lightMode)
|
||||
const darkLabel = useMessage(appearanceMessages.darkMode)
|
||||
const systemLabel = useMessage(appearanceMessages.followSystem)
|
||||
const descriptions: Record<ThemeMode, string> = {
|
||||
light: useMessage(appearanceMessages.lightModeDescription),
|
||||
dark: useMessage(appearanceMessages.darkModeDescription),
|
||||
system: useMessage(appearanceMessages.followSystemDescription),
|
||||
}
|
||||
const items: readonly AppearanceOption<ThemeMode>[] = [
|
||||
{ icon: <SunIcon />, label: lightLabel, value: "light" },
|
||||
{ icon: <MoonIcon />, label: darkLabel, value: "dark" },
|
||||
{ icon: <MonitorIcon />, label: systemLabel, value: "system" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{descriptions[value]}
|
||||
</p>
|
||||
<AppearanceOptionList
|
||||
className="mt-2 w-full max-w-64"
|
||||
items={items}
|
||||
label={title}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorPickerVariant,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
} from "./theme-color-picker"
|
||||
export { ThemeConfigCard, type ThemeConfigCardProps } from "./theme-config-card"
|
||||
export {
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./theme-toggle-button"
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { themeColorMessages } from "../../messages"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
getTheme,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
} from "../../theme"
|
||||
|
||||
export type ThemeColor = BaseColor | AccentColor
|
||||
export type ThemeColorPickerVariant = "pill" | "swatch"
|
||||
|
||||
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
|
||||
dark: ThemeColorSwatchScheme
|
||||
light: ThemeColorSwatchScheme
|
||||
name: T
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchScheme {
|
||||
background: string
|
||||
foreground: string
|
||||
}
|
||||
|
||||
export function createThemeColorSwatches<T extends ThemeColor>(
|
||||
colors: readonly T[]
|
||||
): ThemeColorSwatch<T>[] {
|
||||
return colors.map((name) => {
|
||||
const theme = getTheme(name)!
|
||||
|
||||
return {
|
||||
name,
|
||||
light: {
|
||||
background: theme.cssVars.light.accent,
|
||||
foreground: theme.cssVars.light["accent-foreground"],
|
||||
},
|
||||
dark: {
|
||||
background: theme.cssVars.dark.accent,
|
||||
foreground: theme.cssVars.dark["accent-foreground"],
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const baseColorSwatches = createThemeColorSwatches(baseColors)
|
||||
export const accentColorSwatches = createThemeColorSwatches(accentColors)
|
||||
|
||||
export interface ThemeColorPickerProps<T extends ThemeColor> {
|
||||
className?: string
|
||||
onValueChange: (color: T) => void
|
||||
swatches: readonly ThemeColorSwatch<T>[]
|
||||
title: ReactNode
|
||||
value: T
|
||||
variant?: ThemeColorPickerVariant
|
||||
}
|
||||
|
||||
export function ThemeColorPicker<T extends ThemeColor>({
|
||||
className,
|
||||
onValueChange,
|
||||
swatches,
|
||||
title,
|
||||
value,
|
||||
variant = "pill",
|
||||
}: ThemeColorPickerProps<T>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="theme-color-picker"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
variant === "pill" ? "relative rounded-lg border p-4" : "border-0 p-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<legend
|
||||
className={cn(
|
||||
variant === "pill"
|
||||
? "absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground"
|
||||
: "mb-3 px-0 text-sm font-medium text-foreground"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</legend>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center",
|
||||
variant === "pill" ? "gap-2" : "gap-3"
|
||||
)}
|
||||
>
|
||||
{swatches.map((swatch) => (
|
||||
<ThemeColorSwatchButton
|
||||
key={swatch.name}
|
||||
swatch={swatch}
|
||||
pressed={value === swatch.name}
|
||||
variant={variant}
|
||||
onClick={() => onValueChange(swatch.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchButtonProps {
|
||||
onClick: VoidFunction
|
||||
pressed: boolean
|
||||
swatch: ThemeColorSwatch
|
||||
variant?: ThemeColorPickerVariant
|
||||
}
|
||||
|
||||
export function ThemeColorSwatchButton({
|
||||
onClick,
|
||||
pressed,
|
||||
swatch,
|
||||
variant = "pill",
|
||||
}: ThemeColorSwatchButtonProps) {
|
||||
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
||||
const style = {
|
||||
"--light-bg": swatch.light.background,
|
||||
"--light-fg": swatch.light.foreground,
|
||||
"--dark-bg": swatch.dark.background,
|
||||
"--dark-fg": swatch.dark.foreground,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="theme-color-swatch"
|
||||
data-variant={variant}
|
||||
aria-label={swatchTitle}
|
||||
aria-pressed={pressed}
|
||||
title={variant === "swatch" ? swatchTitle : undefined}
|
||||
className={cn(
|
||||
"appearance-none bg-(--light-bg) dark:bg-(--dark-bg)",
|
||||
variant === "pill"
|
||||
? [
|
||||
"h-8 min-w-8 truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
|
||||
"border-(--light-bg) text-(--light-fg) dark:border-(--dark-bg) dark:text-(--dark-fg)",
|
||||
"ring-0 aria-pressed:ring-2",
|
||||
]
|
||||
: [
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-black/10 p-0 ring-offset-2 ring-offset-background transition-[box-shadow,transform]",
|
||||
"hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"aria-pressed:ring-2 aria-pressed:ring-(--light-bg) dark:border-white/15 dark:aria-pressed:ring-(--dark-bg)",
|
||||
]
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{variant === "pill" ? (
|
||||
swatchTitle
|
||||
) : pressed ? (
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-full bg-(--light-fg) text-(--light-bg) shadow-sm dark:bg-(--dark-fg) dark:text-(--dark-bg)">
|
||||
<CheckIcon className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeColorConfig, ThemeScheme } from "../../theme"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme-color-picker"
|
||||
|
||||
export interface ThemeConfigCardProps {
|
||||
className?: string
|
||||
config: ThemeColorConfig
|
||||
onConfigChange: (config: ThemeColorConfig) => void
|
||||
scheme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeConfigCard({
|
||||
className,
|
||||
config,
|
||||
onConfigChange,
|
||||
scheme,
|
||||
}: ThemeConfigCardProps) {
|
||||
return (
|
||||
<section className={cn("w-full max-w-md rounded-xl border", className)}>
|
||||
<header className="flex gap-4 border-b p-3">
|
||||
{scheme === "light" ? (
|
||||
<SunIcon className="size-5" />
|
||||
) : (
|
||||
<MoonIcon className="size-5" />
|
||||
)}
|
||||
<h2>
|
||||
<LocalizedText message={appearanceMessages.themeColors} />
|
||||
</h2>
|
||||
</header>
|
||||
<div className="space-y-4 p-4">
|
||||
<p>
|
||||
<LocalizedText message={appearanceMessages.themeColorsDescription} />
|
||||
</p>
|
||||
<ThemeColorPicker
|
||||
swatches={baseColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.baseColor} />}
|
||||
value={config.baseColor}
|
||||
onValueChange={(baseColor) =>
|
||||
onConfigChange({ ...config, baseColor })
|
||||
}
|
||||
/>
|
||||
<ThemeColorPicker
|
||||
swatches={accentColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.accentColor} />}
|
||||
value={config.accentColor}
|
||||
onValueChange={(accentColor) =>
|
||||
onConfigChange({ ...config, accentColor })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { useResolvedTheme } from "../../hooks/use-resolved-theme"
|
||||
import { useToggleTheme } from "../../hooks/use-toggle-theme"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeScheme } from "../../theme"
|
||||
|
||||
export interface ThemeToggleProps extends Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"children" | "onClick"
|
||||
> {
|
||||
onThemeChange: (theme: ThemeScheme) => void
|
||||
theme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
onThemeChange,
|
||||
theme,
|
||||
...props
|
||||
}: ThemeToggleProps) {
|
||||
const switchToDarkLabel = useMessage(appearanceMessages.switchToDark)
|
||||
const switchToLightLabel = useMessage(appearanceMessages.switchToLight)
|
||||
const nextTheme = theme === "dark" ? "light" : "dark"
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label={nextTheme === "dark" ? switchToDarkLabel : switchToLightLabel}
|
||||
className={cn(
|
||||
"rounded-lg [&_svg]:opacity-45 [&_svg]:transition-opacity hover:[&_svg]:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onClick={() => onThemeChange(nextTheme)}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ThemeToggleButtonProps = Omit<
|
||||
ThemeToggleProps,
|
||||
"onThemeChange" | "theme"
|
||||
>
|
||||
|
||||
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<ThemeToggle {...props} theme={resolvedTheme} onThemeChange={toggleTheme} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
export type Brightness = "dim" | "normal" | "bright"
|
||||
export type MotionMode = "system" | "reduce" | "full"
|
||||
export type ResolvedMotionMode = Exclude<MotionMode, "system">
|
||||
export type TextScale = "100" | "125" | "150" | "200"
|
||||
|
||||
export const brightnessModes = [
|
||||
"dim",
|
||||
"normal",
|
||||
"bright",
|
||||
] as const satisfies readonly Brightness[]
|
||||
export const motionModes = [
|
||||
"system",
|
||||
"reduce",
|
||||
"full",
|
||||
] as const satisfies readonly MotionMode[]
|
||||
export const textScales = [
|
||||
"100",
|
||||
"125",
|
||||
"150",
|
||||
"200",
|
||||
] as const satisfies readonly TextScale[]
|
||||
|
||||
export const BRIGHTNESS_ATTRIBUTE = "data-ui-brightness"
|
||||
export const MOTION_ATTRIBUTE = "data-ui-motion"
|
||||
export const TEXT_SCALE_ATTRIBUTE = "data-ui-text-scale"
|
||||
|
||||
const surfaceVariableNames = [
|
||||
"background",
|
||||
"card",
|
||||
"popover",
|
||||
"secondary",
|
||||
"muted",
|
||||
"highlight",
|
||||
"sidebar",
|
||||
] as const
|
||||
|
||||
export function isBrightness(value: unknown): value is Brightness {
|
||||
return brightnessModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function isMotionMode(value: unknown): value is MotionMode {
|
||||
return motionModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function isTextScale(value: unknown): value is TextScale {
|
||||
return textScales.some((scale) => scale === value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts interface surfaces without filtering media, illustrations, or brand
|
||||
* colors. Foreground colors are preserved so dimming does not reduce contrast.
|
||||
*/
|
||||
export function applyBrightnessToTheme(
|
||||
variables: Record<string, string>,
|
||||
brightness: Brightness,
|
||||
scheme: ThemeScheme
|
||||
): Record<string, string> {
|
||||
if (brightness === "normal") return variables
|
||||
|
||||
const target = brightness === "dim" ? "black" : "white"
|
||||
const amount = {
|
||||
bright: scheme === "light" ? 12 : 8,
|
||||
dim: scheme === "light" ? 5 : 12,
|
||||
}[brightness]
|
||||
const adjusted = { ...variables }
|
||||
|
||||
for (const name of surfaceVariableNames) {
|
||||
const value = variables[name]
|
||||
if (value) {
|
||||
adjusted[name] = `color-mix(in oklch,${value},${target} ${amount}%)`
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider, usePreference } from "@workspace/preferences"
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import { appearancePreferenceEffect } from "./effects"
|
||||
import { defaultAppearancePreferences } from "./preferences"
|
||||
import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
|
||||
import {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
} from "./display"
|
||||
import { THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 1)
|
||||
)
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const style = document.createElement("style")
|
||||
style.id = THEME_STYLE_ELEMENT_ID
|
||||
document.head.append(style)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
|
||||
document.documentElement.className = ""
|
||||
document.documentElement.removeAttribute(COLOR_VISION_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(BRIGHTNESS_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(MOTION_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(TEXT_SCALE_ATTRIBUTE)
|
||||
document.documentElement.style.colorScheme = ""
|
||||
})
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setThemeMode("dark")}>
|
||||
{themeMode}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearancePreferenceEffect", () => {
|
||||
it("integrates with the shared PreferencesProvider", () => {
|
||||
const onPreferenceChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={defaultAppearancePreferences}
|
||||
onPreferenceChange={onPreferenceChange}
|
||||
>
|
||||
<PreferenceProbe />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:light")).toBe(true)
|
||||
expect(document.documentElement.getAttribute(COLOR_VISION_ATTRIBUTE)).toBe(
|
||||
"standard"
|
||||
)
|
||||
expect(document.documentElement.getAttribute(BRIGHTNESS_ATTRIBUTE)).toBe(
|
||||
"normal"
|
||||
)
|
||||
expect(document.documentElement.getAttribute(MOTION_ATTRIBUTE)).toBe("full")
|
||||
expect(document.documentElement.getAttribute(TEXT_SCALE_ATTRIBUTE)).toBe(
|
||||
"100"
|
||||
)
|
||||
expect(
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
|
||||
).toContain("--background:")
|
||||
|
||||
fireEvent.click(screen.getByRole("button"))
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:dark")).toBe(true)
|
||||
expect(onPreferenceChange).toHaveBeenCalledWith({
|
||||
key: "theme-mode",
|
||||
value: "dark",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
type PreferenceEffect,
|
||||
type PreferenceEffectCallback,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import type { AppearancePreferences } from "./preferences"
|
||||
import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
|
||||
import {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
} from "./display"
|
||||
import { buildThemeStyleSheet, THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
import {
|
||||
getSystemMotion,
|
||||
getSystemTheme,
|
||||
subscribeToSystemMotion,
|
||||
subscribeToSystemTheme,
|
||||
} from "./internal/system-preferences"
|
||||
const appearancePreferenceKeys = [
|
||||
"accent-color",
|
||||
"base-color",
|
||||
"brightness",
|
||||
"color-vision-mode",
|
||||
"motion-mode",
|
||||
"text-scale",
|
||||
"theme-mode",
|
||||
] as const satisfies readonly (keyof AppearancePreferences)[]
|
||||
|
||||
let restoreThemeTransitionFrame: number | undefined
|
||||
|
||||
function disableThemeTransitionsTemporarily() {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.add("ui:theme-changing")
|
||||
|
||||
if (restoreThemeTransitionFrame !== undefined) {
|
||||
cancelAnimationFrame(restoreThemeTransitionFrame)
|
||||
}
|
||||
|
||||
restoreThemeTransitionFrame = requestAnimationFrame(() => {
|
||||
restoreThemeTransitionFrame = requestAnimationFrame(() => {
|
||||
root.classList.remove("ui:theme-changing")
|
||||
restoreThemeTransitionFrame = undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function applyAppearancePreferences(preferences: AppearancePreferences) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const resolvedTheme =
|
||||
preferences["theme-mode"] === "system"
|
||||
? getSystemTheme()
|
||||
: preferences["theme-mode"]
|
||||
const resolvedMotion =
|
||||
preferences["motion-mode"] === "system"
|
||||
? getSystemMotion()
|
||||
: preferences["motion-mode"]
|
||||
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
|
||||
|
||||
if (themeStyle) {
|
||||
themeStyle.textContent = buildThemeStyleSheet(
|
||||
{
|
||||
accentColor: preferences["accent-color"],
|
||||
baseColor: preferences["base-color"],
|
||||
},
|
||||
preferences["color-vision-mode"],
|
||||
preferences.brightness
|
||||
)
|
||||
}
|
||||
|
||||
root.classList.toggle("ui:dark", resolvedTheme === "dark")
|
||||
root.classList.toggle("ui:light", resolvedTheme === "light")
|
||||
root.setAttribute(BRIGHTNESS_ATTRIBUTE, preferences.brightness)
|
||||
root.setAttribute(COLOR_VISION_ATTRIBUTE, preferences["color-vision-mode"])
|
||||
root.setAttribute(MOTION_ATTRIBUTE, resolvedMotion)
|
||||
root.setAttribute(TEXT_SCALE_ATTRIBUTE, preferences["text-scale"])
|
||||
root.style.colorScheme = resolvedTheme
|
||||
}
|
||||
|
||||
/** Applies appearance preferences before the browser paints the updated UI. */
|
||||
export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
|
||||
previousPreferences,
|
||||
preferences,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
const previousAppearancePreferences =
|
||||
previousPreferences as AppearancePreferences
|
||||
|
||||
if (
|
||||
appearancePreferenceKeys.some(
|
||||
(key) =>
|
||||
!Object.is(
|
||||
previousAppearancePreferences[key],
|
||||
appearancePreferences[key]
|
||||
)
|
||||
)
|
||||
) {
|
||||
disableThemeTransitionsTemporarily()
|
||||
}
|
||||
|
||||
applyAppearancePreferences(appearancePreferences)
|
||||
}
|
||||
|
||||
/** Keeps system-mode themes synchronized with the operating system setting. */
|
||||
export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
|
||||
preferences,
|
||||
store,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["theme-mode"] !== "system") return
|
||||
|
||||
const applySystemTheme = () => {
|
||||
disableThemeTransitionsTemporarily()
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
return subscribeToSystemTheme(applySystemTheme)
|
||||
}
|
||||
|
||||
/** Keeps system-mode motion synchronized with the operating system setting. */
|
||||
export const subscribeSystemMotionPreferenceEffect: PreferenceEffectCallback =
|
||||
({ preferences, store }) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["motion-mode"] !== "system") return
|
||||
|
||||
const applySystemMotion = () => {
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
return subscribeToSystemMotion(applySystemMotion)
|
||||
}
|
||||
|
||||
/** Subscribes to every operating-system preference used by Appearance. */
|
||||
export const subscribeSystemAppearancePreferenceEffect: PreferenceEffectCallback =
|
||||
(context) => {
|
||||
const cleanups = [
|
||||
subscribeSystemThemePreferenceEffect(context),
|
||||
subscribeSystemMotionPreferenceEffect(context),
|
||||
].filter(
|
||||
(cleanup): cleanup is VoidFunction => typeof cleanup === "function"
|
||||
)
|
||||
|
||||
if (cleanups.length === 0) return
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/** Register this effect with the application's shared `PreferencesProvider`. */
|
||||
export const appearancePreferenceEffect: PreferenceEffect = {
|
||||
layoutEffect: applyAppearancePreferenceEffect,
|
||||
effect: subscribeSystemAppearancePreferenceEffect,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider } from "@workspace/preferences"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { defaultAppearancePreferences } from "../preferences"
|
||||
import {
|
||||
useAccentColor,
|
||||
useAppearancePreferences,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "./use-appearance-preference"
|
||||
import { useResolvedMotion } from "./use-resolved-motion"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
import { useToggleTheme } from "./use-toggle-theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderPreferences(node: React.ReactNode) {
|
||||
return render(
|
||||
<PreferencesProvider initialPreferences={defaultAppearancePreferences}>
|
||||
{node}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [themeMode] = useThemeMode()
|
||||
const preferences = useAppearancePreferences()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setAccentColor("rose")}>
|
||||
{accentColor}:{themeMode}:{preferences.brightness}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfigProbe() {
|
||||
const [config, setConfig] = useThemeConfig()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfig({ accentColor: "violet", baseColor: "zinc" })}
|
||||
>
|
||||
{config.accentColor}:{config.baseColor}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedPreferenceProbe() {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggleTheme}>
|
||||
{resolvedTheme}:{resolvedMotion}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearance hooks", () => {
|
||||
it("exposes typed preference-specific hooks and the complete snapshot", () => {
|
||||
renderPreferences(<PreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:system:normal")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("rose:system:normal")
|
||||
})
|
||||
|
||||
it("updates accent and base colors through one theme config API", () => {
|
||||
renderPreferences(<ThemeConfigProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:neutral")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("violet:zinc")
|
||||
})
|
||||
|
||||
it("resolves system preferences and toggles to an explicit theme", () => {
|
||||
renderPreferences(<ResolvedPreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("light:full")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("dark:full")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
useAccentColor,
|
||||
useAppearancePreference,
|
||||
useAppearancePreferences,
|
||||
useBaseColor,
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
type AppearancePreferenceState,
|
||||
} from "./use-appearance-preference"
|
||||
export { useResolvedMotion, useSystemMotion } from "./use-resolved-motion"
|
||||
export { useResolvedTheme, useSystemTheme } from "./use-resolved-theme"
|
||||
export { useToggleTheme } from "./use-toggle-theme"
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react"
|
||||
import { usePreference, usePreferences } from "@workspace/preferences"
|
||||
|
||||
import type {
|
||||
AppearancePreferenceKey,
|
||||
AppearancePreferences,
|
||||
AppearancePreferenceValue,
|
||||
} from "../preferences"
|
||||
import type { ThemeColorConfig } from "../theme"
|
||||
|
||||
export type AppearancePreferenceState<Key extends AppearancePreferenceKey> = [
|
||||
AppearancePreferenceValue<Key>,
|
||||
React.Dispatch<React.SetStateAction<AppearancePreferenceValue<Key>>>,
|
||||
]
|
||||
|
||||
/** Reads and updates one preference owned by the Appearance package. */
|
||||
export function useAppearancePreference<Key extends AppearancePreferenceKey>(
|
||||
key: Key
|
||||
): AppearancePreferenceState<Key> {
|
||||
return usePreference(key)
|
||||
}
|
||||
|
||||
/** Returns the complete Appearance-owned portion of the shared preferences. */
|
||||
export function useAppearancePreferences(): AppearancePreferences {
|
||||
const preferences = usePreferences()
|
||||
const accentColor = preferences["accent-color"]
|
||||
const baseColor = preferences["base-color"]
|
||||
const brightness = preferences.brightness
|
||||
const colorVisionMode = preferences["color-vision-mode"]
|
||||
const motionMode = preferences["motion-mode"]
|
||||
const textScale = preferences["text-scale"]
|
||||
const themeMode = preferences["theme-mode"]
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
"accent-color": accentColor,
|
||||
"base-color": baseColor,
|
||||
brightness,
|
||||
"color-vision-mode": colorVisionMode,
|
||||
"motion-mode": motionMode,
|
||||
"text-scale": textScale,
|
||||
"theme-mode": themeMode,
|
||||
}),
|
||||
[
|
||||
accentColor,
|
||||
baseColor,
|
||||
brightness,
|
||||
colorVisionMode,
|
||||
motionMode,
|
||||
textScale,
|
||||
themeMode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function useAccentColor() {
|
||||
return useAppearancePreference("accent-color")
|
||||
}
|
||||
|
||||
export function useBaseColor() {
|
||||
return useAppearancePreference("base-color")
|
||||
}
|
||||
|
||||
export function useBrightness() {
|
||||
return useAppearancePreference("brightness")
|
||||
}
|
||||
|
||||
export function useColorVisionMode() {
|
||||
return useAppearancePreference("color-vision-mode")
|
||||
}
|
||||
|
||||
export function useMotionMode() {
|
||||
return useAppearancePreference("motion-mode")
|
||||
}
|
||||
|
||||
export function useTextScale() {
|
||||
return useAppearancePreference("text-scale")
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
return useAppearancePreference("theme-mode")
|
||||
}
|
||||
|
||||
/** Treats the two color preferences as one controlled theme configuration. */
|
||||
export function useThemeConfig(): [
|
||||
ThemeColorConfig,
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>,
|
||||
] {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [baseColor, setBaseColor] = useBaseColor()
|
||||
const config = React.useMemo(
|
||||
() => ({ accentColor, baseColor }),
|
||||
[accentColor, baseColor]
|
||||
)
|
||||
const configRef = React.useRef(config)
|
||||
configRef.current = config
|
||||
|
||||
const setConfig = React.useCallback<
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>
|
||||
>(
|
||||
(action) => {
|
||||
const nextConfig =
|
||||
typeof action === "function" ? action(configRef.current) : action
|
||||
configRef.current = nextConfig
|
||||
setAccentColor(nextConfig.accentColor)
|
||||
setBaseColor(nextConfig.baseColor)
|
||||
},
|
||||
[setAccentColor, setBaseColor]
|
||||
)
|
||||
|
||||
return React.useMemo(() => [config, setConfig], [config, setConfig])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import {
|
||||
getServerSystemMotion,
|
||||
getSystemMotion,
|
||||
subscribeToSystemMotion,
|
||||
} from "../internal/system-preferences"
|
||||
import { useMotionMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system reduced-motion preference. */
|
||||
export function useSystemMotion(): ResolvedMotionMode {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemMotion,
|
||||
getSystemMotion,
|
||||
getServerSystemMotion
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system motion preference. */
|
||||
export function useResolvedMotion(): ResolvedMotionMode {
|
||||
const [motionMode] = useMotionMode()
|
||||
const systemMotion = useSystemMotion()
|
||||
|
||||
return motionMode === "system" ? systemMotion : motionMode
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import {
|
||||
getServerSystemTheme,
|
||||
getSystemTheme,
|
||||
subscribeToSystemTheme,
|
||||
} from "../internal/system-preferences"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system color scheme independently of theme mode. */
|
||||
export function useSystemTheme(): ThemeScheme {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
getServerSystemTheme
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system color scheme. */
|
||||
export function useResolvedTheme(): ThemeScheme {
|
||||
const [themeMode] = useThemeMode()
|
||||
const systemTheme = useSystemTheme()
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react"
|
||||
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
|
||||
/** Returns a stable callback that toggles between explicit light and dark modes. */
|
||||
export function useToggleTheme(): VoidFunction {
|
||||
const [, setThemeMode] = useThemeMode()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return React.useCallback(() => {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setThemeMode])
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export {
|
||||
AppearanceController,
|
||||
type AppearanceControllerProps,
|
||||
} from "./components/appearance-controller"
|
||||
export {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
AppearanceDialogHeader,
|
||||
AppearanceDialogTrigger,
|
||||
AppearanceTrigger,
|
||||
appearanceDialogHandle,
|
||||
createAppearanceDialogHandle,
|
||||
type AppearanceDialogContentProps,
|
||||
type AppearanceDialogHandle,
|
||||
type AppearanceDialogProps,
|
||||
type AppearanceDialogTriggerProps,
|
||||
} from "./components/appearance-dialog"
|
||||
export {
|
||||
AppearanceHotkeys,
|
||||
type AppearanceHotkeysProps,
|
||||
} from "./components/appearance-hotkeys"
|
||||
export {
|
||||
AppearanceSettings,
|
||||
type AppearanceSettingsProps,
|
||||
} from "./components/appearance-settings"
|
||||
export {
|
||||
BrightnessField,
|
||||
ColorVisionModeField,
|
||||
MotionModeField,
|
||||
TextScaleField,
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type BrightnessFieldProps,
|
||||
type ColorVisionModeFieldProps,
|
||||
type MotionModeFieldProps,
|
||||
type TextScaleFieldProps,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./components/fields"
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
TextScaleOptionList,
|
||||
ThemeModeOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
type TextScaleOptionListProps,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./components/option-lists"
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
ThemeConfigCard,
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorPickerVariant,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
type ThemeConfigCardProps,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./components/theme"
|
||||
export {
|
||||
COLOR_VISION_ATTRIBUTE,
|
||||
colorVisionModes,
|
||||
colorVisionPalettes,
|
||||
getColorVisionCssVariables,
|
||||
isColorVisionMode,
|
||||
type ColorVisionMode,
|
||||
} from "./color-vision"
|
||||
export {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
applyBrightnessToTheme,
|
||||
brightnessModes,
|
||||
isBrightness,
|
||||
isMotionMode,
|
||||
isTextScale,
|
||||
motionModes,
|
||||
textScales,
|
||||
type Brightness,
|
||||
type MotionMode,
|
||||
type ResolvedMotionMode,
|
||||
type TextScale,
|
||||
} from "./display"
|
||||
export {
|
||||
applyAppearancePreferenceEffect,
|
||||
applyAppearancePreferences,
|
||||
appearancePreferenceEffect,
|
||||
subscribeSystemAppearancePreferenceEffect,
|
||||
subscribeSystemMotionPreferenceEffect,
|
||||
subscribeSystemThemePreferenceEffect,
|
||||
} from "./effects"
|
||||
export {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
defaultThemeColorConfig,
|
||||
isAppearancePreferenceUpdate,
|
||||
isThemeColorConfig,
|
||||
type AppearancePreferenceKey,
|
||||
type AppearancePreferences,
|
||||
type AppearancePreferenceUpdate,
|
||||
type AppearancePreferenceValue,
|
||||
type ThemeMode,
|
||||
} from "./preferences"
|
||||
export * from "./theme"
|
||||
export * from "./hooks"
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
|
||||
export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)"
|
||||
export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
||||
|
||||
function subscribeToMediaQuery(query: string, listener: VoidFunction) {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}
|
||||
|
||||
export function subscribeToSystemTheme(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(SYSTEM_THEME_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemTheme(): ThemeScheme {
|
||||
if (typeof window === "undefined") return getServerSystemTheme()
|
||||
return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function getServerSystemTheme(): ThemeScheme {
|
||||
return "light"
|
||||
}
|
||||
|
||||
export function subscribeToSystemMotion(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(REDUCED_MOTION_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemMotion(): ResolvedMotionMode {
|
||||
if (typeof window === "undefined") return getServerSystemMotion()
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches ? "reduce" : "full"
|
||||
}
|
||||
|
||||
export function getServerSystemMotion(): ResolvedMotionMode {
|
||||
return "full"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages"
|
||||
import { appearanceCatalogLocales } from "./catalogs"
|
||||
import { messages as enUS } from "./en-US"
|
||||
import { messages as zhHans } from "./zh-Hans"
|
||||
|
||||
describe("appearance locale catalogs", () => {
|
||||
it("ships every message in every built-in locale", () => {
|
||||
const ids = [
|
||||
...Object.values(appearanceMessages),
|
||||
...Object.values(themeColorMessages),
|
||||
].map((descriptor) => descriptor.id)
|
||||
const catalogs = { "en-US": enUS, "zh-Hans": zhHans }
|
||||
|
||||
expect(appearanceCatalogLocales).toEqual(["en-US", "zh-Hans"])
|
||||
for (const locale of appearanceCatalogLocales) {
|
||||
expect(Object.keys(catalogs[locale])).toHaveLength(ids.length)
|
||||
expect(ids.every((id) => id in catalogs[locale])).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { appearanceMessages, themeColorMessages } from "../messages"
|
||||
|
||||
export const appearanceCatalogLocales = ["en-US", "zh-Hans"] as const
|
||||
|
||||
interface MessageDescriptorMap {
|
||||
readonly [key: string]: {
|
||||
readonly id: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
|
||||
|
||||
export type AppearanceCatalogLocale = (typeof appearanceCatalogLocales)[number]
|
||||
export type AppearanceMessageCatalog = Readonly<
|
||||
Record<
|
||||
MessageId<typeof appearanceMessages> | MessageId<typeof themeColorMessages>,
|
||||
string
|
||||
>
|
||||
>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages.ts"
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en-US"
|
||||
export const languageTag = "en-US"
|
||||
|
||||
function createSourceCatalog(
|
||||
...groups: readonly Record<string, MessageDescriptor>[]
|
||||
): AppearanceMessageCatalog {
|
||||
return Object.fromEntries(
|
||||
groups.flatMap((group) =>
|
||||
Object.values(group).map((descriptor) => [
|
||||
descriptor.id,
|
||||
descriptor.message,
|
||||
])
|
||||
)
|
||||
) as AppearanceMessageCatalog
|
||||
}
|
||||
|
||||
export const messages = createSourceCatalog(
|
||||
appearanceMessages,
|
||||
themeColorMessages
|
||||
) satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
|
||||
export const messages = {
|
||||
"appearance.accentColor": "强调色",
|
||||
"appearance.baseColor": "基础颜色",
|
||||
"appearance.brightness.bright": "明亮",
|
||||
"appearance.brightness.description": "调整界面表面,不改变图片和视频",
|
||||
"appearance.brightness.dim": "柔暗",
|
||||
"appearance.brightness.normal": "标准",
|
||||
"appearance.brightness.title": "界面亮度",
|
||||
"appearance.color.amber": "琥珀色",
|
||||
"appearance.color.blue": "蓝色",
|
||||
"appearance.color.cyan": "青色",
|
||||
"appearance.color.emerald": "翠绿色",
|
||||
"appearance.color.fuchsia": "紫红色",
|
||||
"appearance.color.green": "绿色",
|
||||
"appearance.color.indigo": "靛蓝色",
|
||||
"appearance.color.lime": "青柠色",
|
||||
"appearance.color.mauve": "紫灰",
|
||||
"appearance.color.mist": "雾灰",
|
||||
"appearance.color.neutral": "中性灰",
|
||||
"appearance.color.olive": "橄榄灰",
|
||||
"appearance.color.orange": "橙色",
|
||||
"appearance.color.pink": "粉色",
|
||||
"appearance.color.purple": "紫色",
|
||||
"appearance.color.red": "红色",
|
||||
"appearance.color.rose": "玫红色",
|
||||
"appearance.color.sky": "天蓝色",
|
||||
"appearance.color.stone": "石灰",
|
||||
"appearance.color.taupe": "灰褐",
|
||||
"appearance.color.teal": "蓝绿色",
|
||||
"appearance.color.violet": "紫罗兰色",
|
||||
"appearance.color.yellow": "黄色",
|
||||
"appearance.color.zinc": "锌灰",
|
||||
"appearance.colorVision.assisted.description":
|
||||
"使用更容易相互区分的语义色板;关键信息仍应搭配文字、图标或形状",
|
||||
"appearance.colorVision.deutan": "绿色辨识辅助(Deutan)",
|
||||
"appearance.colorVision.protan": "红色辨识辅助(Protan)",
|
||||
"appearance.colorVision.standard": "标准颜色",
|
||||
"appearance.colorVision.standard.description": "使用所选强调色和默认语义颜色",
|
||||
"appearance.colorVision.title": "色彩辨识辅助",
|
||||
"appearance.colorVision.tritan": "蓝黄色辨识辅助(Tritan)",
|
||||
"appearance.command.description": "打开外观与无障碍偏好",
|
||||
"appearance.description": "主题、颜色、文字、动态效果与界面亮度偏好",
|
||||
"appearance.mode.dark": "深色",
|
||||
"appearance.mode.dark.description": "界面将始终使用深色主题",
|
||||
"appearance.mode.light": "浅色",
|
||||
"appearance.mode.light.description": "界面将始终使用浅色主题",
|
||||
"appearance.mode.system": "系统",
|
||||
"appearance.mode.system.description": "界面主题将跟随系统外观设置",
|
||||
"appearance.mode.title": "主题模式",
|
||||
"appearance.motion.full": "完整动效",
|
||||
"appearance.motion.full.description": "允许所有界面动画和过渡效果",
|
||||
"appearance.motion.reduce": "减少动态效果",
|
||||
"appearance.motion.reduce.description": "减少非必要动画和平滑滚动",
|
||||
"appearance.motion.system": "跟随系统",
|
||||
"appearance.motion.system.description": "跟随操作系统的动态效果偏好",
|
||||
"appearance.motion.title": "动态效果",
|
||||
"appearance.textScale.description":
|
||||
"同步缩放文字、控件与间距,使内容能够重新排版",
|
||||
"appearance.textScale.title": "文字大小",
|
||||
"appearance.themeColors": "主题颜色",
|
||||
"appearance.themeColors.description": "为所有主题模式选择一组基础色和强调色",
|
||||
"appearance.title": "界面外观",
|
||||
"appearance.toggleTheme": "切换主题",
|
||||
"appearance.toggleTheme.dark": "切换为深色模式",
|
||||
"appearance.toggleTheme.description": "在浅色与深色主题之间切换",
|
||||
"appearance.toggleTheme.light": "切换为浅色模式",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const appearanceMessages = {
|
||||
accentColor: /* i18n */ {
|
||||
id: "appearance.accentColor",
|
||||
message: "Accent color",
|
||||
},
|
||||
appearance: /* i18n */ {
|
||||
id: "appearance.title",
|
||||
message: "Appearance",
|
||||
},
|
||||
appearanceCommandDescription: /* i18n */ {
|
||||
id: "appearance.command.description",
|
||||
message: "Open appearance and accessibility preferences",
|
||||
},
|
||||
baseColor: /* i18n */ {
|
||||
id: "appearance.baseColor",
|
||||
message: "Base color",
|
||||
},
|
||||
brightness: /* i18n */ {
|
||||
id: "appearance.brightness.title",
|
||||
message: "Brightness",
|
||||
},
|
||||
brightnessBright: /* i18n */ {
|
||||
id: "appearance.brightness.bright",
|
||||
message: "Bright",
|
||||
},
|
||||
brightnessDescription: /* i18n */ {
|
||||
id: "appearance.brightness.description",
|
||||
message: "Adjusts interface surfaces without changing media",
|
||||
},
|
||||
brightnessDim: /* i18n */ {
|
||||
id: "appearance.brightness.dim",
|
||||
message: "Dim",
|
||||
},
|
||||
brightnessNormal: /* i18n */ {
|
||||
id: "appearance.brightness.normal",
|
||||
message: "Normal",
|
||||
},
|
||||
colorVisionAssistedDescription: /* i18n */ {
|
||||
id: "appearance.colorVision.assisted.description",
|
||||
message: "Uses a palette with more distinguishable semantic colors",
|
||||
},
|
||||
colorVisionDeutan: /* i18n */ {
|
||||
id: "appearance.colorVision.deutan",
|
||||
message: "Green-weak assistance (deutan)",
|
||||
},
|
||||
colorVisionMode: /* i18n */ {
|
||||
id: "appearance.colorVision.title",
|
||||
message: "Color-vision assistance",
|
||||
},
|
||||
colorVisionProtan: /* i18n */ {
|
||||
id: "appearance.colorVision.protan",
|
||||
message: "Red-weak assistance (protan)",
|
||||
},
|
||||
colorVisionStandard: /* i18n */ {
|
||||
id: "appearance.colorVision.standard",
|
||||
message: "Standard colors",
|
||||
},
|
||||
colorVisionStandardDescription: /* i18n */ {
|
||||
id: "appearance.colorVision.standard.description",
|
||||
message: "Uses the selected accent and the default semantic colors",
|
||||
},
|
||||
colorVisionTritan: /* i18n */ {
|
||||
id: "appearance.colorVision.tritan",
|
||||
message: "Blue-yellow assistance (tritan)",
|
||||
},
|
||||
darkMode: /* i18n */ {
|
||||
id: "appearance.mode.dark",
|
||||
message: "Dark",
|
||||
},
|
||||
darkModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.dark.description",
|
||||
message: "The interface will always use the dark theme",
|
||||
},
|
||||
followSystem: /* i18n */ {
|
||||
id: "appearance.mode.system",
|
||||
message: "System",
|
||||
},
|
||||
followSystemDescription: /* i18n */ {
|
||||
id: "appearance.mode.system.description",
|
||||
message: "The interface theme will follow the system appearance",
|
||||
},
|
||||
lightMode: /* i18n */ {
|
||||
id: "appearance.mode.light",
|
||||
message: "Light",
|
||||
},
|
||||
lightModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.light.description",
|
||||
message: "The interface will always use the light theme",
|
||||
},
|
||||
motionFull: /* i18n */ {
|
||||
id: "appearance.motion.full",
|
||||
message: "Full motion",
|
||||
},
|
||||
motionFullDescription: /* i18n */ {
|
||||
id: "appearance.motion.full.description",
|
||||
message: "Allows all interface motion and transitions",
|
||||
},
|
||||
motionMode: /* i18n */ {
|
||||
id: "appearance.motion.title",
|
||||
message: "Motion",
|
||||
},
|
||||
motionReduce: /* i18n */ {
|
||||
id: "appearance.motion.reduce",
|
||||
message: "Reduce motion",
|
||||
},
|
||||
motionReduceDescription: /* i18n */ {
|
||||
id: "appearance.motion.reduce.description",
|
||||
message: "Minimizes non-essential animation and smooth scrolling",
|
||||
},
|
||||
motionSystem: /* i18n */ {
|
||||
id: "appearance.motion.system",
|
||||
message: "System",
|
||||
},
|
||||
motionSystemDescription: /* i18n */ {
|
||||
id: "appearance.motion.system.description",
|
||||
message: "Follows the operating system motion preference",
|
||||
},
|
||||
appearanceDescription: /* i18n */ {
|
||||
id: "appearance.description",
|
||||
message: "Theme, color, text, motion, and brightness preferences",
|
||||
},
|
||||
themeColors: /* i18n */ {
|
||||
id: "appearance.themeColors",
|
||||
message: "Theme colors",
|
||||
},
|
||||
themeColorsDescription: /* i18n */ {
|
||||
id: "appearance.themeColors.description",
|
||||
message: "Choose one base color and accent color for every theme mode",
|
||||
},
|
||||
themeMode: /* i18n */ {
|
||||
id: "appearance.mode.title",
|
||||
message: "Theme mode",
|
||||
},
|
||||
textScale: /* i18n */ {
|
||||
id: "appearance.textScale.title",
|
||||
message: "Text size",
|
||||
},
|
||||
textScaleDescription: /* i18n */ {
|
||||
id: "appearance.textScale.description",
|
||||
message: "Scales text, controls, and spacing so content can reflow",
|
||||
},
|
||||
toggleTheme: /* i18n */ {
|
||||
id: "appearance.toggleTheme",
|
||||
message: "Toggle theme",
|
||||
},
|
||||
toggleThemeDescription: /* i18n */ {
|
||||
id: "appearance.toggleTheme.description",
|
||||
message: "Switch between light and dark themes",
|
||||
},
|
||||
switchToDark: /* i18n */ {
|
||||
id: "appearance.toggleTheme.dark",
|
||||
message: "Switch to dark mode",
|
||||
},
|
||||
switchToLight: /* i18n */ {
|
||||
id: "appearance.toggleTheme.light",
|
||||
message: "Switch to light mode",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
|
||||
export const themeColorMessages = {
|
||||
amber: /* i18n */ {
|
||||
id: "appearance.color.amber",
|
||||
message: "Amber",
|
||||
},
|
||||
blue: /* i18n */ {
|
||||
id: "appearance.color.blue",
|
||||
message: "Blue",
|
||||
},
|
||||
cyan: /* i18n */ {
|
||||
id: "appearance.color.cyan",
|
||||
message: "Cyan",
|
||||
},
|
||||
emerald: /* i18n */ {
|
||||
id: "appearance.color.emerald",
|
||||
message: "Emerald",
|
||||
},
|
||||
fuchsia: /* i18n */ {
|
||||
id: "appearance.color.fuchsia",
|
||||
message: "Fuchsia",
|
||||
},
|
||||
green: /* i18n */ {
|
||||
id: "appearance.color.green",
|
||||
message: "Green",
|
||||
},
|
||||
indigo: /* i18n */ {
|
||||
id: "appearance.color.indigo",
|
||||
message: "Indigo",
|
||||
},
|
||||
lime: /* i18n */ {
|
||||
id: "appearance.color.lime",
|
||||
message: "Lime",
|
||||
},
|
||||
mauve: /* i18n */ {
|
||||
id: "appearance.color.mauve",
|
||||
message: "Mauve",
|
||||
},
|
||||
mist: /* i18n */ {
|
||||
id: "appearance.color.mist",
|
||||
message: "Mist",
|
||||
},
|
||||
neutral: /* i18n */ {
|
||||
id: "appearance.color.neutral",
|
||||
message: "Neutral",
|
||||
},
|
||||
olive: /* i18n */ {
|
||||
id: "appearance.color.olive",
|
||||
message: "Olive",
|
||||
},
|
||||
orange: /* i18n */ {
|
||||
id: "appearance.color.orange",
|
||||
message: "Orange",
|
||||
},
|
||||
pink: /* i18n */ {
|
||||
id: "appearance.color.pink",
|
||||
message: "Pink",
|
||||
},
|
||||
purple: /* i18n */ {
|
||||
id: "appearance.color.purple",
|
||||
message: "Purple",
|
||||
},
|
||||
red: /* i18n */ {
|
||||
id: "appearance.color.red",
|
||||
message: "Red",
|
||||
},
|
||||
rose: /* i18n */ {
|
||||
id: "appearance.color.rose",
|
||||
message: "Rose",
|
||||
},
|
||||
sky: /* i18n */ {
|
||||
id: "appearance.color.sky",
|
||||
message: "Sky",
|
||||
},
|
||||
stone: /* i18n */ {
|
||||
id: "appearance.color.stone",
|
||||
message: "Stone",
|
||||
},
|
||||
taupe: /* i18n */ {
|
||||
id: "appearance.color.taupe",
|
||||
message: "Taupe",
|
||||
},
|
||||
teal: /* i18n */ {
|
||||
id: "appearance.color.teal",
|
||||
message: "Teal",
|
||||
},
|
||||
violet: /* i18n */ {
|
||||
id: "appearance.color.violet",
|
||||
message: "Violet",
|
||||
},
|
||||
yellow: /* i18n */ {
|
||||
id: "appearance.color.yellow",
|
||||
message: "Yellow",
|
||||
},
|
||||
zinc: /* i18n */ {
|
||||
id: "appearance.color.zinc",
|
||||
message: "Zinc",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
isAppearancePreferenceUpdate,
|
||||
} from "./preferences"
|
||||
|
||||
describe("appearance preferences", () => {
|
||||
it("creates stable defaults", () => {
|
||||
expect(defaultAppearancePreferences).toEqual({
|
||||
"accent-color": "blue",
|
||||
"base-color": "neutral",
|
||||
brightness: "normal",
|
||||
"color-vision-mode": "standard",
|
||||
"motion-mode": "system",
|
||||
"text-scale": "100",
|
||||
"theme-mode": "system",
|
||||
})
|
||||
})
|
||||
|
||||
it("serializes and validates preference updates", () => {
|
||||
const accentColor = appearancePreferenceDefinitions["accent-color"]
|
||||
const colorVisionMode = appearancePreferenceDefinitions["color-vision-mode"]
|
||||
|
||||
expect(accentColor.parse(accentColor.serialize("violet"))).toBe("violet")
|
||||
expect(colorVisionMode.parse("deutan")).toBe("deutan")
|
||||
expect(colorVisionMode.parse("unknown")).toBe("standard")
|
||||
expect(appearancePreferenceDefinitions.brightness.parse("dim")).toBe("dim")
|
||||
expect(appearancePreferenceDefinitions["motion-mode"].parse("bad")).toBe(
|
||||
"system"
|
||||
)
|
||||
expect(appearancePreferenceDefinitions["text-scale"].parse("200")).toBe(
|
||||
"200"
|
||||
)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({
|
||||
key: "color-vision-mode",
|
||||
value: "tritan",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
createPreferenceUpdateGuard,
|
||||
type PreferenceDefinitions,
|
||||
type PreferenceUpdateFor,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import { isColorVisionMode, type ColorVisionMode } from "./color-vision"
|
||||
import {
|
||||
isBrightness,
|
||||
isMotionMode,
|
||||
isTextScale,
|
||||
type Brightness,
|
||||
type MotionMode,
|
||||
type TextScale,
|
||||
} from "./display"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type ThemeColorConfig,
|
||||
} from "./theme"
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system"
|
||||
|
||||
export interface AppearancePreferences {
|
||||
"accent-color": AccentColor
|
||||
"base-color": BaseColor
|
||||
brightness: Brightness
|
||||
"color-vision-mode": ColorVisionMode
|
||||
"motion-mode": MotionMode
|
||||
"text-scale": TextScale
|
||||
"theme-mode": ThemeMode
|
||||
}
|
||||
|
||||
declare module "@workspace/preferences" {
|
||||
interface PreferencesCustom extends AppearancePreferences {}
|
||||
}
|
||||
|
||||
export type AppearancePreferenceKey = keyof AppearancePreferences
|
||||
export type AppearancePreferenceValue<Key extends AppearancePreferenceKey> =
|
||||
AppearancePreferences[Key]
|
||||
export type AppearancePreferenceUpdate =
|
||||
PreferenceUpdateFor<AppearancePreferences>
|
||||
|
||||
export const defaultThemeColorConfig: ThemeColorConfig = {
|
||||
accentColor: "blue",
|
||||
baseColor: "neutral",
|
||||
}
|
||||
|
||||
export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
|
||||
if (!value || typeof value !== "object") return false
|
||||
|
||||
const config = value as {
|
||||
accentColor?: unknown
|
||||
baseColor?: unknown
|
||||
}
|
||||
|
||||
return (
|
||||
typeof config.baseColor === "string" &&
|
||||
baseColors.some((color) => color === config.baseColor) &&
|
||||
typeof config.accentColor === "string" &&
|
||||
accentColors.some((color) => color === config.accentColor)
|
||||
)
|
||||
}
|
||||
|
||||
function isAccentColor(value: unknown): value is AccentColor {
|
||||
return accentColors.some((color) => color === value)
|
||||
}
|
||||
|
||||
function isBaseColor(value: unknown): value is BaseColor {
|
||||
return baseColors.some((color) => color === value)
|
||||
}
|
||||
|
||||
function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
}
|
||||
|
||||
export const appearancePreferenceDefinitions: PreferenceDefinitions<AppearancePreferences> =
|
||||
{
|
||||
"accent-color": {
|
||||
cookie: "ui-accent-color",
|
||||
defaultValue: defaultThemeColorConfig.accentColor,
|
||||
is: isAccentColor,
|
||||
parse: (value) => (isAccentColor(value) ? value : "blue"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"base-color": {
|
||||
cookie: "ui-base-color",
|
||||
defaultValue: defaultThemeColorConfig.baseColor,
|
||||
is: isBaseColor,
|
||||
parse: (value) => (isBaseColor(value) ? value : "neutral"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
brightness: {
|
||||
cookie: "ui-brightness",
|
||||
defaultValue: "normal",
|
||||
is: isBrightness,
|
||||
parse: (value) => (isBrightness(value) ? value : "normal"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"color-vision-mode": {
|
||||
cookie: "ui-color-vision",
|
||||
defaultValue: "standard",
|
||||
is: isColorVisionMode,
|
||||
parse: (value) => (isColorVisionMode(value) ? value : "standard"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"motion-mode": {
|
||||
cookie: "ui-motion",
|
||||
defaultValue: "system",
|
||||
is: isMotionMode,
|
||||
parse: (value) => (isMotionMode(value) ? value : "system"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"text-scale": {
|
||||
cookie: "ui-text-scale",
|
||||
defaultValue: "100",
|
||||
is: isTextScale,
|
||||
parse: (value) => (isTextScale(value) ? value : "100"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"theme-mode": {
|
||||
cookie: "ui-theme",
|
||||
defaultValue: "system",
|
||||
is: isThemeMode,
|
||||
parse: (value) => (isThemeMode(value) ? value : "system"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultAppearancePreferences = createDefaultPreferences(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
|
||||
export const isAppearancePreferenceUpdate = createPreferenceUpdateGuard(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
@source "../";
|
||||
|
||||
.ui\:theme-changing *,
|
||||
.ui\:theme-changing *::before,
|
||||
.ui\:theme-changing *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="100"] {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="125"] {
|
||||
font-size: 125%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="150"] {
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="200"] {
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] *,
|
||||
html[data-ui-motion="reduce"] *::before,
|
||||
html[data-ui-motion="reduce"] *::after {
|
||||
animation-delay: 0ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-delay: 0ms !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
|
||||
@keyframes ui-reduced-motion-status {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] [data-slot="spinner"],
|
||||
html[data-ui-motion="reduce"] [data-slot="skeleton"],
|
||||
html[data-ui-motion="reduce"] [data-slot="toast-icon"] .animate-spin {
|
||||
animation: ui-reduced-motion-status 2s ease-in-out infinite !important;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
buildThemeStyleSheet,
|
||||
getTheme,
|
||||
getThemeCssVariables,
|
||||
themes,
|
||||
} from "./theme"
|
||||
|
||||
describe("appearance themes", () => {
|
||||
it("defines every selectable base and accent color exactly once", () => {
|
||||
const selectableColors = [...baseColors, ...accentColors]
|
||||
|
||||
expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
|
||||
expect(new Set(selectableColors).size).toBe(selectableColors.length)
|
||||
expect(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
|
||||
})
|
||||
|
||||
it("combines base and accent palettes", () => {
|
||||
const variables = getThemeCssVariables("light", {
|
||||
accentColor: "rose",
|
||||
baseColor: "stone",
|
||||
})
|
||||
|
||||
expect(variables.background).toBe(
|
||||
getTheme("stone")?.cssVars.light.background
|
||||
)
|
||||
expect(variables.highlight).toBe(getTheme("stone")?.cssVars.light.highlight)
|
||||
expect(variables.accent).toBe(getTheme("rose")?.cssVars.light.accent)
|
||||
})
|
||||
|
||||
it("builds explicit and system color-scheme rules", () => {
|
||||
const styleSheet = buildThemeStyleSheet(
|
||||
{ accentColor: "blue", baseColor: "neutral" },
|
||||
"deutan",
|
||||
"dim"
|
||||
)
|
||||
|
||||
expect(styleSheet).toContain(":root,.ui\\:light{")
|
||||
expect(styleSheet).toContain(".ui\\:dark{")
|
||||
expect(styleSheet).toContain("@media (prefers-color-scheme:dark)")
|
||||
expect(styleSheet).toContain("--background:")
|
||||
expect(styleSheet).toContain("--accent:")
|
||||
expect(styleSheet).toContain("--highlight:")
|
||||
expect(styleSheet).toContain("--destructive:#a64b00")
|
||||
expect(styleSheet).toContain("--destructive:#ff9d66")
|
||||
expect(styleSheet).toContain("--background:color-mix(in oklch,")
|
||||
expect(styleSheet).not.toContain("filter:")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
themes,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./internal/themes"
|
||||
import {
|
||||
getColorVisionCssVariables,
|
||||
type ColorVisionMode,
|
||||
} from "./color-vision"
|
||||
import { applyBrightnessToTheme, type Brightness } from "./display"
|
||||
|
||||
export {
|
||||
accentColors,
|
||||
baseColors,
|
||||
themes,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type Theme,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./internal/themes"
|
||||
|
||||
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
|
||||
|
||||
export const getTheme = (name: BaseColor | AccentColor) =>
|
||||
themes.find((theme) => theme.name === name)
|
||||
|
||||
export function getThemeCssVariables(
|
||||
scheme: ThemeScheme,
|
||||
config: ThemeColorConfig
|
||||
) {
|
||||
const base = getTheme(config.baseColor) ?? getTheme("neutral")!
|
||||
const accent = getTheme(config.accentColor) ?? getTheme("blue")!
|
||||
|
||||
return {
|
||||
...base.cssVars[scheme],
|
||||
...accent.cssVars[scheme],
|
||||
}
|
||||
}
|
||||
|
||||
function serializeThemeVariables(theme: Record<string, string>): string {
|
||||
return Object.entries(theme)
|
||||
.map(([key, value]) => `--${key}:${value}`)
|
||||
.join(";")
|
||||
}
|
||||
|
||||
export function buildThemeStyleSheet(
|
||||
config: ThemeColorConfig,
|
||||
colorVisionMode: ColorVisionMode = "standard",
|
||||
brightness: Brightness = "normal"
|
||||
) {
|
||||
const light = serializeThemeVariables(
|
||||
applyBrightnessToTheme(
|
||||
{
|
||||
...getThemeCssVariables("light", config),
|
||||
...getColorVisionCssVariables(colorVisionMode, "light"),
|
||||
},
|
||||
brightness,
|
||||
"light"
|
||||
)
|
||||
)
|
||||
const dark = serializeThemeVariables(
|
||||
applyBrightnessToTheme(
|
||||
{
|
||||
...getThemeCssVariables("dark", config),
|
||||
...getColorVisionCssVariables(colorVisionMode, "dark"),
|
||||
},
|
||||
brightness,
|
||||
"dark"
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
`:root,.ui\\:light{${light}}`,
|
||||
`.ui\\:dark{${dark}}`,
|
||||
`@media (prefers-color-scheme:dark){:root:not(.ui\\:light):not(.ui\\:dark){${dark}}}`,
|
||||
].join("")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/appearance": ["./src/index.ts"],
|
||||
"@workspace/appearance/*": ["./src/*"],
|
||||
"@workspace/i18n": ["../i18n/src/index.ts"],
|
||||
"@workspace/preferences": ["../preferences/src/index.tsx"],
|
||||
"@workspace/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
group: application
|
||||
order: 10
|
||||
label: Blocks
|
||||
description:
|
||||
zh-Hans: 构建应用外壳,并接入导航、通知、聊天和外观控制。
|
||||
en-US: Build an application shell and add navigation, notifications, chat, and appearance controls.
|
||||
@@ -33,12 +33,12 @@ function createSwatches<T extends ThemeColor>(
|
||||
return {
|
||||
name,
|
||||
light: {
|
||||
background: theme.cssVars.light.primary,
|
||||
foreground: theme.cssVars.light["primary-foreground"],
|
||||
background: theme.cssVars.light.accent,
|
||||
foreground: theme.cssVars.light["accent-foreground"],
|
||||
},
|
||||
dark: {
|
||||
background: theme.cssVars.dark.primary,
|
||||
foreground: theme.cssVars.dark["primary-foreground"],
|
||||
background: theme.cssVars.dark.accent,
|
||||
foreground: theme.cssVars.dark["accent-foreground"],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ThemePreview({
|
||||
...Object.fromEntries(
|
||||
Object.entries(themeVariables).map(([key, value]) => [`--${key}`, value])
|
||||
),
|
||||
"--preview-base": baseTheme.cssVars[scheme].primary,
|
||||
"--preview-base": baseTheme.cssVars[scheme].accent,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
@@ -39,7 +39,7 @@ export function ThemePreview({
|
||||
<div className="flex gap-3">
|
||||
<div className="flex w-16 flex-col gap-1 py-5 [&>div]:bg-foreground/10">
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="h-2.5 w-full rounded-r-full bg-primary!" />
|
||||
<div className="h-2.5 w-full rounded-r-full bg-accent!" />
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="mt-auto h-2.5 w-full rounded-r-full" />
|
||||
@@ -50,7 +50,7 @@ export function ThemePreview({
|
||||
<div className="h-2.5 w-3 rounded-sm" />
|
||||
<div className="ml-auto size-2.5 rounded-sm" />
|
||||
<div className="h-2.5 w-3.75 rounded-sm" />
|
||||
<div className="h-2.5 w-5 rounded-sm bg-primary!" />
|
||||
<div className="h-2.5 w-5 rounded-sm bg-accent!" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10">
|
||||
<div className="flex h-18 items-end gap-2 p-2 compacted:gap-1">
|
||||
@@ -85,8 +85,8 @@ export function ThemePreview({
|
||||
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55 compacted:hidden" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-6 rounded-sm bg-primary" />
|
||||
<div className="h-2 w-6 rounded-sm bg-primary/75" />
|
||||
<div className="h-2 w-6 rounded-sm bg-accent" />
|
||||
<div className="h-2 w-6 rounded-sm bg-accent/75" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-18" />
|
||||
|
||||
@@ -27,7 +27,8 @@ describe("appearance themes", () => {
|
||||
expect(variables.background).toBe(
|
||||
getTheme("stone")?.cssVars.light.background
|
||||
)
|
||||
expect(variables.primary).toBe(getTheme("rose")?.cssVars.light.primary)
|
||||
expect(variables.highlight).toBe(getTheme("stone")?.cssVars.light.highlight)
|
||||
expect(variables.accent).toBe(getTheme("rose")?.cssVars.light.accent)
|
||||
})
|
||||
|
||||
it("builds explicit and system color-scheme rules", () => {
|
||||
@@ -40,6 +41,7 @@ describe("appearance themes", () => {
|
||||
expect(styleSheet).toContain(".ui\\:dark{")
|
||||
expect(styleSheet).toContain("@media (prefers-color-scheme:dark)")
|
||||
expect(styleSheet).toContain("--background:")
|
||||
expect(styleSheet).toContain("--primary:")
|
||||
expect(styleSheet).toContain("--accent:")
|
||||
expect(styleSheet).toContain("--highlight:")
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ export function ChatConversationList({
|
||||
className={cn(
|
||||
"group grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3.5 rounded-xl px-3 py-3 text-start transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
active &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
"bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
)}
|
||||
>
|
||||
<ConversationAvatar conversation={conversation} />
|
||||
@@ -82,7 +82,7 @@ export function ChatConversationList({
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs text-muted-foreground",
|
||||
active && "text-primary-foreground/70"
|
||||
active && "text-accent-foreground/70"
|
||||
)}
|
||||
>
|
||||
{conversation.lastActivityLabel}
|
||||
@@ -91,7 +91,7 @@ export function ChatConversationList({
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm text-muted-foreground",
|
||||
active && "text-primary-foreground/75"
|
||||
active && "text-accent-foreground/75"
|
||||
)}
|
||||
>
|
||||
{conversation.lastMessage ?? conversation.description}
|
||||
@@ -99,8 +99,8 @@ export function ChatConversationList({
|
||||
{conversation.unreadCount && conversation.unreadCount > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-5 shrink-0 place-items-center self-end justify-self-end rounded-full bg-primary text-xs font-medium text-primary-foreground",
|
||||
active && "bg-primary-foreground text-primary"
|
||||
"grid size-5 shrink-0 place-items-center self-end justify-self-end rounded-full bg-accent text-xs font-medium text-accent-foreground",
|
||||
active && "bg-accent-foreground text-accent"
|
||||
)}
|
||||
>
|
||||
{conversation.unreadCount > 99
|
||||
|
||||
@@ -210,7 +210,7 @@ function BreadcrumbNavigationMenuItem({
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
className="aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary"
|
||||
className="aria-[current=page]:bg-accent/10 aria-[current=page]:text-accent"
|
||||
>
|
||||
<BreadcrumbEntryContent entry={entry} />
|
||||
</DropdownMenuSubTrigger>
|
||||
@@ -241,7 +241,7 @@ function BreadcrumbNavigationMenuItem({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
className="aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary"
|
||||
className="aria-[current=page]:bg-accent/10 aria-[current=page]:text-accent"
|
||||
render={
|
||||
entry.to === "/" ? (
|
||||
<Link to="/" />
|
||||
@@ -406,7 +406,7 @@ export function AppBreadcrumb() {
|
||||
{item.kind === "menu" ? (
|
||||
<BreadcrumbMenu entries={item.entries} />
|
||||
) : isCurrent ? (
|
||||
<BreadcrumbPage className="inline-flex min-w-0 items-center gap-1.5 text-primary">
|
||||
<BreadcrumbPage className="inline-flex min-w-0 items-center gap-1.5 text-accent">
|
||||
{item.entry.icon && (
|
||||
<Icon
|
||||
name={item.entry.icon}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="size-4.5 animate-spin rounded-full border-2 border-foreground/10 border-t-primary" />
|
||||
<span className="size-4.5 animate-spin rounded-full border-2 border-foreground/10 border-t-accent" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function HeaderActionButton({
|
||||
{showIndicator && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-1 right-1 inline-flex size-2 animate-pulse rounded-md bg-primary"
|
||||
className="absolute top-1 right-1 inline-flex size-2 animate-pulse rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function SidebarFlyoutMenuItems({
|
||||
<DropdownMenuSubTrigger
|
||||
aria-current={routeState.isCurrent ? "page" : undefined}
|
||||
data-route-active={routeState.isActive ? "" : undefined}
|
||||
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
|
||||
className="h-9 gap-3 px-3 py-1 data-route-active:bg-accent/10 data-route-active:text-accent! data-route-active:**:text-accent!"
|
||||
>
|
||||
{iconElement}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
@@ -56,7 +56,7 @@ export function SidebarFlyoutMenuItems({
|
||||
key={item.id}
|
||||
aria-current={routeState.isCurrent ? "page" : undefined}
|
||||
data-route-active={routeState.isActive ? "" : undefined}
|
||||
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
|
||||
className="h-9 gap-3 px-3 py-1 data-route-active:bg-accent/10 data-route-active:text-accent! data-route-active:**:text-accent!"
|
||||
render={item.to ? <NavigationLink to={item.to} /> : undefined}
|
||||
>
|
||||
{iconElement}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NavigationButton({
|
||||
const baseButtonProps: useRender.ElementProps<"button"> = {
|
||||
className: cn(
|
||||
"group/button relative items-center rounded-md [&>span]:inline-flex [&>span]:items-center",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground data-route-active:bg-primary/10 data-route-active:text-primary dark:hover:bg-muted/50 dark:aria-expanded:bg-muted/50 dark:data-route-active:bg-muted/50",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground data-route-active:bg-accent/10 data-route-active:text-accent dark:hover:bg-muted/50 dark:aria-expanded:bg-muted/50 dark:data-route-active:bg-muted/50",
|
||||
layout === "stacked"
|
||||
? [
|
||||
"grid grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]",
|
||||
|
||||
@@ -156,7 +156,7 @@ export function NotificationItem({
|
||||
role="listitem"
|
||||
data-read={notification.isRead}
|
||||
className={cn(
|
||||
"relative rounded-none px-4 py-3.5 hover:bg-muted data-[read=false]:bg-primary/[0.035]",
|
||||
"relative rounded-none px-4 py-3.5 hover:bg-muted data-[read=false]:bg-accent/[0.035]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -167,7 +167,7 @@ export function NotificationItem({
|
||||
</ItemMedia>
|
||||
)}
|
||||
<ItemContent>
|
||||
<ItemTitle className="line-clamp-2 [&_a:hover]:text-primary">
|
||||
<ItemTitle className="line-clamp-2 [&_a:hover]:text-accent">
|
||||
{notification.to ? (
|
||||
<NotificationRouteLink to={notification.to}>
|
||||
{notification.title}
|
||||
@@ -204,7 +204,7 @@ export function NotificationItem({
|
||||
{!notification.isRead && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-2 size-2 shrink-0 self-start rounded-full bg-primary"
|
||||
className="mt-2 size-2 shrink-0 self-start rounded-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
{notification.actions && notification.actions.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Geometry and export
|
||||
description: Work with source coordinates, pure functions, and Canvas output.
|
||||
order: 20
|
||||
toc:
|
||||
- id: controlled-state
|
||||
title: Controlled state
|
||||
- id: geometry
|
||||
title: Geometry functions
|
||||
- id: export
|
||||
title: Exporting an image
|
||||
- id: api
|
||||
title: API
|
||||
- id: cross-origin
|
||||
title: Cross-origin images
|
||||
---
|
||||
|
||||
## Controlled state {#controlled-state}
|
||||
|
||||
```tsx
|
||||
const [value, setValue] = React.useState({ x: 0, y: 0, zoom: 1 })
|
||||
|
||||
<Cropper
|
||||
src={src}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onCropComplete={(area) => saveDraft(area)}
|
||||
/>
|
||||
```
|
||||
|
||||
`onCropAreaChange` runs whenever a valid crop changes. `onCropComplete` runs after drag or wheel interaction and after discrete keyboard operations, making it a better persistence boundary.
|
||||
|
||||
## Geometry functions {#geometry}
|
||||
|
||||
`getCoverScale`, `getCropperBounds`, `constrainCropperValue`, `getCropArea`, and `zoomCropperValueAtPoint` are DOM-independent pure functions that can be reused in custom interfaces and tests.
|
||||
|
||||
## Exporting an image {#export}
|
||||
|
||||
The shortest path exports the current result through the component ref:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
const result = await cropperRef.current?.getCropImage({
|
||||
outputType: "base64",
|
||||
type: "image/jpeg",
|
||||
quality: 0.9,
|
||||
width: 1200,
|
||||
})
|
||||
|
||||
<Cropper ref={cropperRef} src={src} />
|
||||
```
|
||||
|
||||
`getCropImage()` returns `undefined` until the image is ready. `outputType` defaults to `"blob"`; `"base64"` returns a data URL suitable for previews or form values. Quality must be between `0` and `1`.
|
||||
|
||||
Use the pure function when no component instance is available:
|
||||
|
||||
```tsx
|
||||
const blob = await cropImage(src, area, {
|
||||
type: "image/webp",
|
||||
quality: 0.9,
|
||||
width: 1200,
|
||||
})
|
||||
```
|
||||
|
||||
Supplying only `width` or `height` preserves the crop aspect ratio. `shape: "circle"` clips through an ellipse; provide `background` when an opaque JPEG background is required. `getCropImage(source, area, options)` is the corresponding pure function for Blob/base64 output.
|
||||
|
||||
## API {#api}
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------------------------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `src` | `string \| Blob` | An image URL, Blob, or File. |
|
||||
| `aspectRatio` | `number \| \`${number}:${number}\`` | Crop viewport ratio; defaults to `1`. |
|
||||
| `width` / `height` | matching `CSSProperties` value | Optional inline container dimensions. |
|
||||
| `value` / `defaultValue` | `CropperValue` | Controlled or uncontrolled translation and zoom. |
|
||||
| `minZoom` / `maxZoom` | `number` | Zoom range; defaults to `1` through `4`. |
|
||||
| `shape` | `"rectangle" \| "circle"` | Viewport shape. |
|
||||
| `showGrid` | `boolean` | Whether to render the rule-of-thirds grid. |
|
||||
| `onCropAreaChange` | `(area) => void` | Runs when the valid source-image crop changes. |
|
||||
| `onCropComplete` | `(area, value) => void` | Runs after an interaction or reset completes. |
|
||||
| `onImageLoad` / `onImageError` | callbacks | Observe image loading state. |
|
||||
|
||||
`CropperHandle` exposes `element`, `getCropArea()`, `getCropImage(options)`, and `reset()`. Use `rootRef` when a raw DOM ref is needed; the component `ref` is reserved for the imperative handle.
|
||||
|
||||
## Cross-origin images {#cross-origin}
|
||||
|
||||
A remote image server must return CORS headers that permit Canvas use. Pass `crossOrigin="anonymous"` to `Cropper` and the matching `crossOrigin` option to `cropImage`; otherwise the browser may taint the Canvas and prevent Blob encoding.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Avatar crop
|
||||
description: Build a circular avatar preview and export a fixed-size PNG.
|
||||
order: 30
|
||||
toc:
|
||||
- id: example
|
||||
title: Example
|
||||
---
|
||||
|
||||
## Example {#example}
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
function AvatarCropper({ src }: { src: string }) {
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
async function saveAvatar() {
|
||||
const blob = await cropperRef.current?.getCropImage({
|
||||
height: 512,
|
||||
shape: "circle",
|
||||
type: "image/png",
|
||||
width: 512,
|
||||
})
|
||||
if (blob instanceof Blob) await uploadAvatar(blob)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Cropper ref={cropperRef} src={src} shape="circle" className="size-72" />
|
||||
<button type="button" onClick={saveAvatar}>
|
||||
Save avatar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Basic usage
|
||||
description: Add the stylesheet source and create an interactive image cropper.
|
||||
order: 10
|
||||
toc:
|
||||
- id: stylesheet
|
||||
title: Stylesheet
|
||||
- id: cropper
|
||||
title: Cropper
|
||||
- id: controls
|
||||
title: External controls
|
||||
---
|
||||
|
||||
## Stylesheet {#stylesheet}
|
||||
|
||||
Add the package source to the application's Tailwind stylesheet:
|
||||
|
||||
```css
|
||||
@import "@workspace/cropper/globals.css";
|
||||
```
|
||||
|
||||
## Cropper {#cropper}
|
||||
|
||||
Use classes or pass `width` and `height` directly. `aspectRatio` accepts a number or a `"width:height"` string:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropArea } from "@workspace/cropper"
|
||||
|
||||
function ProductImageCropper({ src }: { src: string }) {
|
||||
const [area, setArea] = React.useState<CropArea>()
|
||||
|
||||
return (
|
||||
<Cropper
|
||||
src={src}
|
||||
alt="Product"
|
||||
aspectRatio="4:3"
|
||||
className="w-full max-w-xl rounded-xl"
|
||||
onCropAreaChange={setArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Users can drag the image, scroll to zoom, or focus the Cropper and use the arrow, `+`, `-`, `0`, and `Home` keys.
|
||||
|
||||
`src` can also be a `Blob` or `File` from a file picker. The component creates a temporary object URL and revokes it when the source changes or the component unmounts.
|
||||
|
||||
## External controls {#controls}
|
||||
|
||||
Control the crop with `value` and `onValueChange` to connect a Slider. For a quick reset, call `reset()` on the component ref. `minZoom` is clamped to at least `1`, ensuring that the image always covers the viewport.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Overview
|
||||
description: Pan, zoom, calculate, and export image crops in React.
|
||||
order: 1
|
||||
toc:
|
||||
- id: package-role
|
||||
title: Package role
|
||||
- id: capabilities
|
||||
title: Capabilities
|
||||
- id: data-model
|
||||
title: Data model
|
||||
---
|
||||
|
||||
## Package role {#package-role}
|
||||
|
||||
`@workspace/cropper` provides fixed-viewport image cropping. Users move and scale an image inside the viewport while the component reports the crop in source-image pixels. It owns interaction and geometry, but does not upload, store, or mutate the original file.
|
||||
|
||||
## Capabilities {#capabilities}
|
||||
|
||||
- Mouse and touch dragging.
|
||||
- Pinch, wheel, double-click, and keyboard zooming.
|
||||
- Controlled and uncontrolled state.
|
||||
- Rectangular and circular viewports.
|
||||
- Numeric and `"width:height"` viewport ratios.
|
||||
- URL and local `Blob` image sources with automatic object URL cleanup.
|
||||
- Resize-aware image constraints.
|
||||
- Standalone pure geometry functions.
|
||||
- Imperative reset, crop-area access, and export through a component ref.
|
||||
- Canvas export to PNG, JPEG, or WebP Blob/base64 output.
|
||||
|
||||
## Data model {#data-model}
|
||||
|
||||
In `CropperValue`, `x` and `y` are CSS-pixel offsets from the viewport center and `zoom` is relative to the cover fit. `CropArea` uses source-image pixels, so it can be sent to an image service or passed directly to `cropImage()` for browser-side export.
|
||||
@@ -0,0 +1,6 @@
|
||||
group: content
|
||||
order: 40
|
||||
label: Cropper
|
||||
description:
|
||||
zh-Hans: 通过可组合交互、几何计算和 Canvas 导出构建图片裁剪流程。
|
||||
en-US: Build image-cropping workflows with composable interactions, geometry utilities, and Canvas export.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user