Compare commits

...

40 Commits

Author SHA1 Message Date
Maofeng 774495912c style(docs): simplify article sidebar surface 2026-09-30 01:31:54 +08:00
Maofeng 57d006a39b docs(layout): document app layout composition 2026-09-30 01:30:56 +08:00
Maofeng ffda0b9099 feat(layout): add configurable application layout 2026-09-30 01:30:47 +08:00
Maofeng 8b880a19e2 feat(docs): colocate localized package sandboxes 2026-09-29 22:13:17 +08:00
Maofeng e34675a931 docs(appearance): document preference hooks 2026-09-29 22:12:56 +08:00
Maofeng 642cb71b93 refactor(appearance): organize components and hooks 2026-09-29 22:12:40 +08:00
Maofeng 352db2c4c0 docs(appearance): document control variants 2026-09-29 21:29:49 +08:00
Maofeng 983ebead02 feat(appearance): expand preference controls 2026-09-29 21:29:38 +08:00
Maofeng 9a6ec3a396 feat(docs): add sandbox fullscreen mode 2026-09-29 21:04:34 +08:00
Maofeng 4710a56242 docs(appearance): document display preferences 2026-09-29 20:58:46 +08:00
Maofeng aae02d1502 feat(appearance): add accessible display preferences 2026-09-29 20:58:35 +08:00
Maofeng 75d8cbbfc3 feat(appearance): add display preference models 2026-09-29 20:57:59 +08:00
Maofeng 68f142dfa3 feat(docs): group package catalog 2026-09-29 20:56:44 +08:00
Maofeng 24b32ea5cc feat(cropper): expand image export API 2026-09-29 17:46:16 +08:00
Maofeng f56397fb97 feat(docs): discover package metadata 2026-09-29 17:20:25 +08:00
Maofeng 844acad316 docs(cropper): add bilingual usage guides 2026-09-29 17:09:39 +08:00
Maofeng 23e0867027 feat(cropper): add image cropping primitives 2026-09-29 17:09:26 +08:00
Maofeng acbc62463e docs(ui): explain shadcn adaptations 2026-09-29 17:08:23 +08:00
Maofeng ffe180f482 refactor(workspace): consume revised color tokens 2026-09-29 17:08:16 +08:00
Maofeng 91e010cff7 refactor(appearance): adopt accent and highlight tokens 2026-09-29 17:08:08 +08:00
Maofeng d5cec8342d refactor(ui): clarify semantic color roles 2026-09-29 17:08:03 +08:00
Maofeng c39d131a86 docs: register overlays and page packages 2026-09-28 23:13:54 +08:00
Maofeng d23deca0e7 feat(page): add composable page primitives 2026-09-28 23:13:49 +08:00
Maofeng 0ec968c344 feat(overlays): add managed overlay package 2026-09-28 23:13:44 +08:00
Maofeng bca874fd30 feat(ui): expose typed overlay handles 2026-09-28 23:13:39 +08:00
Maofeng 1353dc3145 docs(record): add bilingual usage guides 2026-09-28 17:24:14 +08:00
Maofeng d3de7cc172 test(record): cover providers fields and iteration 2026-09-28 17:24:08 +08:00
Maofeng 3eb0246c40 feat(record): add generic record context APIs 2026-09-28 17:23:59 +08:00
Maofeng 93373adb9a feat(docs): enhance code presentation 2026-09-28 16:00:01 +08:00
Maofeng 91cfb3d068 refactor(ui): use relative internal imports 2026-09-21 17:32:06 +08:00
Maofeng 4c61e79d0d docs(ui): expand bilingual component guides 2026-09-21 17:27:45 +08:00
Maofeng fb985edb20 feat(docs): add appearance sandbox example 2026-09-21 17:09:54 +08:00
Maofeng a9d6856ab8 feat(docs): add MDX sandbox previews 2026-09-21 17:09:44 +08:00
Maofeng b26c8886cf chore(web): resolve source alias from ESM config 2026-09-21 16:18:33 +08:00
Maofeng 3e671cce47 test(web): cover select disclosure icon 2026-09-21 16:18:29 +08:00
Maofeng 4d3892a5ea fix(docs): stabilize shared layout rendering 2026-09-21 16:17:06 +08:00
Maofeng df5bbc739c refactor(docs): share site header and footer 2026-09-21 16:12:12 +08:00
Maofeng 6711401f5f docs(appearance): add bilingual package guides 2026-09-21 16:05:19 +08:00
Maofeng e2dbbcb884 feat(appearance): add composable theme package 2026-09-21 16:05:05 +08:00
Maofeng 71c416caab fix(ui): render select icon as child 2026-09-20 18:32:14 +08:00
310 changed files with 17327 additions and 862 deletions
+1
View File
@@ -12,6 +12,7 @@ dist
dist-ssr
.ssr
.output
docs/icons
*.local
# env files (can opt-in for committing if needed)
+28
View File
@@ -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)
})
})
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
resolve: {
tsconfigPaths: true,
alias: {
"@": path.resolve(__dirname, "./src"),
"@": path.resolve(import.meta.dirname, "./src"),
},
},
plugins: [
+255
View File
@@ -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
View File
@@ -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"
}
}
}
+104 -5
View File
@@ -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>&nbsp;</span>
<span>R</span>
<span className="text-slate-300 dark:text-slate-400">eact</span>
<span>&nbsp;</span>
<span>A</span>
<span className="text-slate-300 dark:text-slate-400">pp</span>
<span>&nbsp;</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>
)
}
+3
View File
@@ -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
})
}
+15
View File
@@ -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"
+27
View File
@@ -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)
}
+4
View File
@@ -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}
</>
)
}
+519
View File
@@ -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>
}
+35
View File
@@ -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
}
+17
View File
@@ -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
View File
@@ -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)
}
+7
View File
@@ -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
View File
@@ -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);
}
}
+7 -17
View File
@@ -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
View File
@@ -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>&nbsp;</span>
<span>R</span>
<span className="text-slate-300 dark:text-slate-400">eact</span>
<span>&nbsp;</span>
<span>A</span>
<span className="text-slate-300 dark:text-slate-400">pp</span>
<span>&nbsp;</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
View File
@@ -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 />
</>
)
}
+6 -2
View File
@@ -22,5 +22,9 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
"include": [
"src",
"../packages/*/docs/sandboxes/**/*.ts",
"../packages/*/docs/sandboxes/**/*.tsx"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": []
},
"files": ["src/components/sandbox/sandbox-env.d.ts"],
"include": []
}
+43 -2
View File
@@ -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()
```
+6
View File
@@ -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
+7
View File
@@ -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()
```
+58
View File
@@ -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"
}
}
+103
View File
@@ -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} />
)
}
+77
View File
@@ -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
}
+106
View File
@@ -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",
})
})
})
+158
View File
@@ -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")
})
})
+16
View File
@@ -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])
}
+112
View File
@@ -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
>
>
+25
View File
@@ -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
+259
View File
@@ -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)
})
})
+139
View File
@@ -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;
}
+52
View File
@@ -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:")
})
})
+80
View File
@@ -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("")
}
+22
View File
@@ -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"]
}
+6
View File
@@ -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.
+33
View File
@@ -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.
+6
View File
@@ -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