Compare commits
44 Commits
2a6284d1b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 774495912c | |||
| 57d006a39b | |||
| ffda0b9099 | |||
| 8b880a19e2 | |||
| e34675a931 | |||
| 642cb71b93 | |||
| 352db2c4c0 | |||
| 983ebead02 | |||
| 9a6ec3a396 | |||
| 4710a56242 | |||
| aae02d1502 | |||
| 75d8cbbfc3 | |||
| 68f142dfa3 | |||
| 24b32ea5cc | |||
| f56397fb97 | |||
| 844acad316 | |||
| 23e0867027 | |||
| acbc62463e | |||
| ffe180f482 | |||
| 91e010cff7 | |||
| d5cec8342d | |||
| c39d131a86 | |||
| d23deca0e7 | |||
| 0ec968c344 | |||
| bca874fd30 | |||
| 1353dc3145 | |||
| d3de7cc172 | |||
| 3eb0246c40 | |||
| 93373adb9a | |||
| 91cfb3d068 | |||
| 4c61e79d0d | |||
| fb985edb20 | |||
| a9d6856ab8 | |||
| b26c8886cf | |||
| 3e671cce47 | |||
| 4d3892a5ea | |||
| df5bbc739c | |||
| 6711401f5f | |||
| e2dbbcb884 | |||
| 71c416caab | |||
| a6f5f0b649 | |||
| 02df33ce6b | |||
| eebf29c3d5 | |||
| c519d4e74b |
@@ -12,6 +12,7 @@ dist
|
||||
dist-ssr
|
||||
.ssr
|
||||
.output
|
||||
docs/icons
|
||||
*.local
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
|
||||
@@ -1,22 +1,139 @@
|
||||
# Simple React App Kit
|
||||
|
||||
Simple React App Kit is a reusable React application kit built with TanStack
|
||||
Start, shadcn/ui, and workspace packages.
|
||||
Simple React App Kit is a production-oriented React workspace for building application interfaces without assembling the foundation from scratch. It includes a runnable TanStack Start application, shared shadcn/ui components, feature packages, internationalization, documentation, and tested integration patterns.
|
||||
|
||||
## Adding components
|
||||
[Open the documentation](https://react-app-kit.go-slim.dev) · [English documentation](https://react-app-kit.go-slim.dev/en-US/)
|
||||
|
||||
To add components to your app, run the following command at the root of your `web` app:
|
||||
## What you get
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest add button -c apps/web
|
||||
- A TanStack Start application with routing, SSR, responsive layout, and light/dark themes.
|
||||
- Shared shadcn/ui primitives that remain editable source code.
|
||||
- Application-level navigation, notifications, chat, and appearance blocks.
|
||||
- Adapter-based Media and Search packages that do not prescribe a backend.
|
||||
- A composable Lexical editor package.
|
||||
- Type-safe application preferences and an internationalization workflow.
|
||||
- A bilingual MDX documentation site with SSG, search, and Cloudflare deployment.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements:
|
||||
|
||||
- [Bun](https://bun.com/) 1.3.5 or newer
|
||||
- Node.js 22.19 or newer
|
||||
|
||||
Install dependencies and start the example application:
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will place the ui components in the `packages/ui/src/components` directory.
|
||||
The development server prints the local URL when it is ready.
|
||||
|
||||
## Using components
|
||||
To run the documentation site separately:
|
||||
|
||||
To use the components in your app, import them from the `ui` package.
|
||||
```sh
|
||||
bun run --cwd docs dev
|
||||
```
|
||||
|
||||
## Workspace packages
|
||||
|
||||
The packages are private workspace packages by default. Use them inside this repository with `workspace:*`, or adapt and publish them under your own scope.
|
||||
|
||||
| Package | Use it for |
|
||||
| --- | --- |
|
||||
| `@workspace/ui` | Shared components, hooks, icons, design tokens, and global styles |
|
||||
| `@workspace/blocks` | Application shell, navigation, notifications, chat, and appearance controls |
|
||||
| `@workspace/media` | Media browsing, uploads, folders, asset selection, and storage adapters |
|
||||
| `@workspace/search` | Application search, grouped results, pagination, and search history |
|
||||
| `@workspace/lexical` | Rich-text editing, actions, media nodes, and editor presets |
|
||||
| `@workspace/i18n` | Locale runtime, catalogs, extraction, compilation, and development tools |
|
||||
| `@workspace/preferences` | Typed external state, persistence callbacks, SSR snapshots, and effects |
|
||||
|
||||
The intended boundary is simple: your application owns APIs, persistence, routing decisions, and business rules; workspace packages own reusable presentation and interaction behavior.
|
||||
|
||||
## Using a package
|
||||
|
||||
Add a workspace dependency to the consuming application:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@workspace/media": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Import the package stylesheet once when it provides one. This also lets Tailwind discover the utilities used by that package:
|
||||
|
||||
```css
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/media/globals.css";
|
||||
```
|
||||
|
||||
Then import only the public API you need:
|
||||
|
||||
```tsx
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
MediaLibrary,
|
||||
MediaProvider,
|
||||
type MediaAdapter,
|
||||
} from "@workspace/media"
|
||||
|
||||
export function MediaPage({ adapter }: { adapter: MediaAdapter }) {
|
||||
return (
|
||||
<MediaProvider adapter={adapter}>
|
||||
<MediaLibrary />
|
||||
</MediaProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Package-specific setup, adapters, locale catalogs, and progressive examples are documented on the [documentation site](https://react-app-kit.go-slim.dev).
|
||||
|
||||
## Adding shadcn/ui components
|
||||
|
||||
Run the shadcn CLI from the repository root:
|
||||
|
||||
```sh
|
||||
bunx shadcn@latest add button -c apps/web
|
||||
```
|
||||
|
||||
The workspace aliases place generated components in `packages/ui/src/components`. Import them through the UI package:
|
||||
|
||||
```tsx
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
```
|
||||
|
||||
Generated components belong to the repository and can be changed to match the product instead of being treated as an opaque dependency.
|
||||
|
||||
## Common commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `bun run dev` | Start the example web application |
|
||||
| `bun run build` | Build the web application |
|
||||
| `bun run check` | Check formatting, lint, and all workspace types |
|
||||
| `bun run format` | Format application and package source files |
|
||||
| `bun run lint` | Run Oxlint across the repository |
|
||||
| `bun run typecheck` | Type-check every workspace |
|
||||
| `bun run --cwd packages/media test` | Run tests for one package |
|
||||
| `bun run --cwd docs build` | Generate the static documentation site |
|
||||
| `bun run --cwd docs deploy` | Build and deploy the docs to Cloudflare |
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
apps/web/ Runnable TanStack Start application
|
||||
docs/ Documentation application and SSG pipeline
|
||||
packages/
|
||||
blocks/ Application-level interface blocks
|
||||
i18n/ Internationalization runtime and tooling
|
||||
lexical/ Rich-text editor
|
||||
media/ Media library and picker
|
||||
preferences/ Typed preference state
|
||||
search/ Application search
|
||||
ui/ Shared shadcn/ui components
|
||||
```
|
||||
|
||||
Documentation for each package lives beside its source under `packages/<name>/docs`, so examples and APIs evolve with the implementation.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"@workspace/blocks/appearance/locales/{locale}",
|
||||
"@workspace/blocks/chats/locales/{locale}",
|
||||
"@workspace/blocks/layout/locales/{locale}",
|
||||
"@workspace/blocks/media/locales/{locale}",
|
||||
"@workspace/media/locales/{locale}",
|
||||
"@workspace/blocks/navigation/locales/{locale}",
|
||||
"@workspace/blocks/notifications/locales/{locale}",
|
||||
"@workspace/search/locales/{locale}",
|
||||
@@ -16,6 +16,7 @@
|
||||
"include": [
|
||||
"src",
|
||||
"../../packages/blocks/src",
|
||||
"../../packages/media/src",
|
||||
"../../packages/search/src",
|
||||
"../../packages/ui/src"
|
||||
],
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@workspace/blocks": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/lexical": "workspace:*",
|
||||
"@workspace/media": "workspace:*",
|
||||
"@workspace/search": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe("SelectTrigger", () => {
|
||||
it("renders the disclosure icon without forwarding fallback children to its svg", () => {
|
||||
render(
|
||||
<Select items={[{ label: "System", value: "system" }]} value="system">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole("combobox")
|
||||
const icon = trigger.querySelector("[aria-hidden='true'] > svg")
|
||||
|
||||
expect(icon).not.toBeNull()
|
||||
expect(icon?.childNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -183,7 +183,7 @@ function Field({
|
||||
Component: FieldInputComponent
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="relative flex h-12 items-center rounded-lg border-2 border-border p-0 focus-within:border-primary focus-within:[&_label]:opacity-100">
|
||||
<fieldset className="relative flex h-12 items-center rounded-lg border-2 border-border p-0 focus-within:border-accent focus-within:[&_label]:opacity-100">
|
||||
<legend className="ml-2 flex h-0 px-0">
|
||||
<label
|
||||
className="h-fit -translate-y-1/2 px-1 text-sm opacity-50"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/blocks/globals.css";
|
||||
@import "@workspace/lexical/globals.css";
|
||||
@import "@workspace/media/globals.css";
|
||||
@import "@workspace/search/globals.css";
|
||||
|
||||
:root {
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@workspace/blocks": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/lexical": "workspace:*",
|
||||
"@workspace/media": "workspace:*",
|
||||
"@workspace/search": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
@@ -54,7 +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",
|
||||
@@ -74,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": {
|
||||
@@ -108,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",
|
||||
@@ -138,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",
|
||||
@@ -165,6 +230,61 @@
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/media": {
|
||||
"name": "@workspace/media",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@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/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",
|
||||
@@ -181,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",
|
||||
@@ -355,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=="],
|
||||
@@ -431,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=="],
|
||||
@@ -773,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=="],
|
||||
@@ -939,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=="],
|
||||
@@ -979,14 +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"],
|
||||
@@ -1031,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=="],
|
||||
@@ -1039,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=="],
|
||||
@@ -1061,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=="],
|
||||
@@ -1087,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=="],
|
||||
@@ -1115,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=="],
|
||||
@@ -1179,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=="],
|
||||
@@ -1257,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=="],
|
||||
@@ -1321,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=="],
|
||||
@@ -1473,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=="],
|
||||
@@ -1483,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=="],
|
||||
@@ -1523,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=="],
|
||||
@@ -1545,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=="],
|
||||
@@ -1597,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=="],
|
||||
@@ -1609,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=="],
|
||||
@@ -1653,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=="],
|
||||
@@ -1677,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=="],
|
||||
@@ -1701,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=="],
|
||||
@@ -1723,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=="],
|
||||
@@ -1735,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=="],
|
||||
@@ -1781,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=="],
|
||||
@@ -1805,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=="],
|
||||
@@ -1829,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=="],
|
||||
@@ -1893,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=="],
|
||||
@@ -1979,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=="],
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>my-shadcn-ui Documentation</title>
|
||||
<title>Simple React App Kit Documentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+12
-2
@@ -12,7 +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",
|
||||
@@ -32,10 +40,12 @@
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"satteri": "^0.10.5",
|
||||
"satteri-expressive-code": "^0.1.24",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-satteri": "^0.3.5",
|
||||
"wrangler": "^4.135.0"
|
||||
"wrangler": "^4.135.0",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { cn } from "../lib/utils"
|
||||
import { useTranslate } from "../lib/locale"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
import React from "react"
|
||||
import { SearchTrigger } from "./search-trigger"
|
||||
import { SettingsLink } from "./settings-link"
|
||||
import { getDocsHomeHref } from "../content/registry"
|
||||
|
||||
export const rootCSSVariables = {
|
||||
"--cw": "60rem",
|
||||
@@ -37,7 +38,7 @@ export function HomeButton({
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400 shrink-0"
|
||||
>
|
||||
<Icon name="tabler:smart-home" className="size-5" />
|
||||
{label ?? t({ zh: "首页", us: "Home" })}
|
||||
@@ -83,11 +84,109 @@ export function SlashLine({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Actions({ className }: { className?: string }) {
|
||||
export function Actions() {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)}>
|
||||
<>
|
||||
<SearchTrigger />
|
||||
<SettingsLink />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
banner,
|
||||
bottom,
|
||||
leading,
|
||||
subleading,
|
||||
actions,
|
||||
locale,
|
||||
...rest
|
||||
}: React.ComponentProps<"header"> & {
|
||||
banner?: React.ReactNode
|
||||
bottom?: React.ReactNode
|
||||
leading?: React.ReactNode
|
||||
subleading?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
locale?: string
|
||||
}) {
|
||||
return (
|
||||
<header {...rest}>
|
||||
{banner}
|
||||
<HeaderInner
|
||||
leading={leading}
|
||||
subleading={subleading}
|
||||
locale={locale}
|
||||
actions={actions}
|
||||
/>
|
||||
{bottom}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderInner({
|
||||
leading,
|
||||
subleading,
|
||||
actions,
|
||||
locale,
|
||||
}: {
|
||||
leading?: React.ReactNode
|
||||
subleading?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
locale?: string
|
||||
}) {
|
||||
const resolvedLocale = useLocale() ?? locale
|
||||
const hasLeading = React.Children.count(leading) > 0
|
||||
const hasSubleading = React.Children.count(subleading) > 0
|
||||
|
||||
return (
|
||||
<Container className="flex size-full items-center gap-4">
|
||||
{hasLeading || hasSubleading ? (
|
||||
<>
|
||||
{hasLeading ? (
|
||||
leading
|
||||
) : (
|
||||
<HomeButton href={getDocsHomeHref(resolvedLocale)} />
|
||||
)}
|
||||
{hasSubleading ? (
|
||||
<>
|
||||
<SlashLine />
|
||||
{subleading}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<h1 className="min-w-0 text-2xl font-semibold sm:text-3xl">
|
||||
<span>S</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">imple</span>
|
||||
<span> </span>
|
||||
<span>R</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">eact</span>
|
||||
<span> </span>
|
||||
<span>A</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">pp</span>
|
||||
<span> </span>
|
||||
<span>K</span>
|
||||
<span className="text-slate-300 dark:text-slate-400">it</span>
|
||||
</h1>
|
||||
)}
|
||||
{actions != null ? (
|
||||
<div className="-mr-2 ml-auto inline-flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteFooter({
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer className={cn("py-8", className)} {...rest}>
|
||||
<Container className="text-center text-slate-500 dark:text-slate-400">
|
||||
<div>Copyright © 2026</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MdxComponents } from "../content/types"
|
||||
import { Sandbox, SingleFileSandbox } from "./sandbox"
|
||||
|
||||
export const mdxComponents: MdxComponents = {
|
||||
a: MdxAnchor,
|
||||
@@ -7,6 +8,8 @@ export const mdxComponents: MdxComponents = {
|
||||
h2: MdxHeading2,
|
||||
h3: MdxHeading3,
|
||||
pre: MdxPre,
|
||||
Sandbox,
|
||||
SingleFileSandbox,
|
||||
}
|
||||
|
||||
function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { HighlighterCore } from "@shikijs/core"
|
||||
|
||||
export type HighlightedToken = {
|
||||
content: string
|
||||
dark: string
|
||||
light: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export type HighlightedLine = {
|
||||
offset: number
|
||||
tokens: HighlightedToken[]
|
||||
}
|
||||
|
||||
export type HighlightedSource = HighlightedLine[]
|
||||
|
||||
const languageLoaders = {
|
||||
bash: () => import("@shikijs/langs/bash"),
|
||||
css: () => import("@shikijs/langs/css"),
|
||||
html: () => import("@shikijs/langs/html"),
|
||||
javascript: () => import("@shikijs/langs/javascript"),
|
||||
json: () => import("@shikijs/langs/json"),
|
||||
jsx: () => import("@shikijs/langs/jsx"),
|
||||
markdown: () => import("@shikijs/langs/markdown"),
|
||||
mdx: () => import("@shikijs/langs/mdx"),
|
||||
tsx: () => import("@shikijs/langs/tsx"),
|
||||
typescript: () => import("@shikijs/langs/typescript"),
|
||||
yaml: () => import("@shikijs/langs/yaml"),
|
||||
} as const
|
||||
|
||||
type SyntaxLanguage = keyof typeof languageLoaders
|
||||
|
||||
const languageByExtension: Readonly<Record<string, SyntaxLanguage>> = {
|
||||
bash: "bash",
|
||||
cjs: "javascript",
|
||||
css: "css",
|
||||
html: "html",
|
||||
js: "javascript",
|
||||
json: "json",
|
||||
jsx: "jsx",
|
||||
md: "markdown",
|
||||
mdx: "mdx",
|
||||
mjs: "javascript",
|
||||
sh: "bash",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
zsh: "bash",
|
||||
}
|
||||
|
||||
const highlighterPromises = new Map<SyntaxLanguage, Promise<HighlighterCore>>()
|
||||
|
||||
export async function highlightSource(
|
||||
source: string,
|
||||
filename: string
|
||||
): Promise<HighlightedSource> {
|
||||
const extension = filename.split(".").pop()?.toLowerCase() ?? ""
|
||||
const language = languageByExtension[extension]
|
||||
if (!language) return createPlainSource(source)
|
||||
|
||||
const highlighter = await getHighlighter(language)
|
||||
const lines = highlighter.codeToTokensWithThemes(source, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
})
|
||||
|
||||
let lineOffset = 0
|
||||
|
||||
return lines.map((line) => {
|
||||
const highlightedLine: HighlightedLine = {
|
||||
offset: lineOffset,
|
||||
tokens: line.map((token) => ({
|
||||
content: token.content,
|
||||
dark: token.variants.dark?.color ?? "currentColor",
|
||||
light: token.variants.light?.color ?? "currentColor",
|
||||
offset: token.offset,
|
||||
})),
|
||||
}
|
||||
|
||||
lineOffset += line.reduce(
|
||||
(length, token) => length + token.content.length,
|
||||
0
|
||||
)
|
||||
lineOffset += 1
|
||||
return highlightedLine
|
||||
})
|
||||
}
|
||||
|
||||
function getHighlighter(language: SyntaxLanguage) {
|
||||
const cached = highlighterPromises.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const highlighter = Promise.all([
|
||||
import("@shikijs/core"),
|
||||
import("@shikijs/engine-javascript"),
|
||||
import("@shikijs/themes/github-dark"),
|
||||
import("@shikijs/themes/github-light"),
|
||||
languageLoaders[language](),
|
||||
]).then(
|
||||
([
|
||||
{ createHighlighterCore },
|
||||
{ createJavaScriptRegexEngine },
|
||||
{ default: githubDark },
|
||||
{ default: githubLight },
|
||||
{ default: grammar },
|
||||
]) =>
|
||||
createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: grammar,
|
||||
themes: [githubDark, githubLight],
|
||||
})
|
||||
)
|
||||
|
||||
highlighterPromises.set(language, highlighter)
|
||||
return highlighter
|
||||
}
|
||||
|
||||
function createPlainSource(source: string): HighlightedSource {
|
||||
let offset = 0
|
||||
|
||||
return source.split("\n").map((content) => {
|
||||
const line: HighlightedLine = {
|
||||
offset,
|
||||
tokens: [
|
||||
{
|
||||
content,
|
||||
dark: "inherit",
|
||||
light: "inherit",
|
||||
offset,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
offset += content.length + 1
|
||||
return line
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
Sandbox,
|
||||
SingleFileSandbox,
|
||||
type SandboxProps,
|
||||
type SingleFileSandboxProps,
|
||||
} from "./sandbox"
|
||||
export {
|
||||
defineSandbox,
|
||||
type SandboxDefinition,
|
||||
type SandboxLocalizedText,
|
||||
type SandboxPreviewProps,
|
||||
type SandboxPreviewModule,
|
||||
type SandboxSource,
|
||||
type SandboxSourceModule,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SandboxDefinition } from "./types"
|
||||
|
||||
type SandboxManifestModule = {
|
||||
default: SandboxDefinition
|
||||
}
|
||||
|
||||
const manifestModules = import.meta.glob<SandboxManifestModule>(
|
||||
[
|
||||
"../../sandboxes/*/*/sandbox.ts",
|
||||
"../../../../packages/*/docs/sandboxes/*/sandbox.ts",
|
||||
],
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
const sandboxDefinitions = new Map<string, SandboxDefinition>()
|
||||
|
||||
for (const module of Object.values(manifestModules)) {
|
||||
const definition = module.default
|
||||
if (sandboxDefinitions.has(definition.id)) {
|
||||
throw new Error(`Duplicate sandbox id: ${definition.id}`)
|
||||
}
|
||||
sandboxDefinitions.set(definition.id, definition)
|
||||
}
|
||||
|
||||
export function getSandboxDefinition(id: string) {
|
||||
return sandboxDefinitions.get(id)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "*?raw" {
|
||||
const source: string
|
||||
export default source
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
|
||||
type SandboxErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback: (error: Error) => React.ReactNode
|
||||
}
|
||||
|
||||
type SandboxErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class SandboxErrorBoundary extends React.Component<
|
||||
SandboxErrorBoundaryProps,
|
||||
SandboxErrorBoundaryState
|
||||
> {
|
||||
state: SandboxErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.error
|
||||
? this.props.fallback(this.state.error)
|
||||
: this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import type { DocLocale } from "../../content/types"
|
||||
|
||||
const minimumFrameHeight = 288
|
||||
|
||||
export function SandboxFrame({
|
||||
children,
|
||||
fill = false,
|
||||
locale,
|
||||
reloadKey,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
fill?: boolean
|
||||
locale: DocLocale
|
||||
reloadKey: number
|
||||
title: string
|
||||
}) {
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null)
|
||||
const [mountNode, setMountNode] = React.useState<HTMLElement | null>(null)
|
||||
const [height, setHeight] = React.useState(minimumFrameHeight)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const frame = frameRef.current
|
||||
const frameDocument = frame?.contentDocument
|
||||
if (!frame || !frameDocument) return
|
||||
|
||||
frameDocument.open()
|
||||
frameDocument.write(
|
||||
'<!doctype html><html><head></head><body><div id="sandbox-root"></div></body></html>'
|
||||
)
|
||||
frameDocument.close()
|
||||
|
||||
for (const node of document.head.querySelectorAll(
|
||||
'link[rel="stylesheet"], style'
|
||||
)) {
|
||||
frameDocument.head.append(node.cloneNode(true))
|
||||
}
|
||||
|
||||
const frameOverrides = frameDocument.createElement("style")
|
||||
frameOverrides.textContent = `
|
||||
html, body, #sandbox-root {
|
||||
min-height: 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
`
|
||||
frameDocument.head.append(frameOverrides)
|
||||
|
||||
const syncDocumentTheme = () => {
|
||||
frameDocument.documentElement.className =
|
||||
document.documentElement.className
|
||||
frameDocument.documentElement.style.colorScheme =
|
||||
document.documentElement.style.colorScheme
|
||||
}
|
||||
syncDocumentTheme()
|
||||
frameDocument.documentElement.lang = locale
|
||||
|
||||
const themeObserver = new MutationObserver(syncDocumentTheme)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributeFilter: ["class", "style"],
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
const nextMountNode = frameDocument.getElementById("sandbox-root")
|
||||
setMountNode(nextMountNode)
|
||||
|
||||
return () => {
|
||||
themeObserver.disconnect()
|
||||
setMountNode(null)
|
||||
}
|
||||
}, [locale, reloadKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mountNode || fill) return
|
||||
|
||||
const frameDocument = mountNode.ownerDocument
|
||||
const updateHeight = () => {
|
||||
setHeight(
|
||||
Math.max(
|
||||
minimumFrameHeight,
|
||||
Math.ceil(frameDocument.documentElement.scrollHeight)
|
||||
)
|
||||
)
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(updateHeight)
|
||||
resizeObserver.observe(frameDocument.body)
|
||||
updateHeight()
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [fill, mountNode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className={`block w-full border-0 bg-white dark:bg-slate-950 ${fill ? "min-h-0 flex-1" : ""}`}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
sandbox="allow-same-origin"
|
||||
title={title}
|
||||
/>
|
||||
{mountNode ? createPortal(children, mountNode) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import React from "react"
|
||||
import { Icon } from "@icones/react"
|
||||
|
||||
import { useLocale, useTranslate } from "../../lib/locale"
|
||||
import { getSandboxDefinition } from "./registry"
|
||||
import { SandboxErrorBoundary } from "./sandbox-error-boundary"
|
||||
import { SandboxFrame } from "./sandbox-frame"
|
||||
import { highlightSource, type HighlightedSource } from "./highlight-source"
|
||||
import type {
|
||||
SandboxDefinition,
|
||||
SandboxLocalizedText,
|
||||
SandboxPreviewProps,
|
||||
SandboxPreviewModule,
|
||||
SandboxSource,
|
||||
} from "./types"
|
||||
|
||||
type SandboxView = "code" | "preview"
|
||||
|
||||
const codeFontSizes = [12, 13, 14, 16] as const
|
||||
|
||||
export type SandboxProps = {
|
||||
className?: string
|
||||
example: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function Sandbox({ className, example, title }: SandboxProps) {
|
||||
const definition = getSandboxDefinition(example)
|
||||
const t = useTranslate()
|
||||
|
||||
if (!definition) {
|
||||
return (
|
||||
<SandboxMessage className={className} tone="error">
|
||||
{t({
|
||||
zh: `找不到 Sandbox 示例:${example}`,
|
||||
us: `Sandbox example not found: ${example}`,
|
||||
})}
|
||||
</SandboxMessage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SandboxViewer
|
||||
className={className}
|
||||
definition={definition}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SingleFileSandboxProps = {
|
||||
className?: string
|
||||
component: React.ComponentType
|
||||
filename?: string
|
||||
source: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function SingleFileSandbox({
|
||||
className,
|
||||
component,
|
||||
filename = "App.tsx",
|
||||
source,
|
||||
title,
|
||||
}: SingleFileSandboxProps) {
|
||||
const definition = React.useMemo<SandboxDefinition>(
|
||||
() => ({
|
||||
defaultFile: filename,
|
||||
entry: async () => ({
|
||||
default: component as React.ComponentType<SandboxPreviewProps>,
|
||||
}),
|
||||
files: { [filename]: source },
|
||||
id: `single-file:${filename}`,
|
||||
title,
|
||||
}),
|
||||
[component, filename, source, title]
|
||||
)
|
||||
|
||||
return <SandboxViewer className={className} definition={definition} />
|
||||
}
|
||||
|
||||
function SandboxViewer({
|
||||
className,
|
||||
definition,
|
||||
title,
|
||||
}: {
|
||||
className?: string
|
||||
definition: SandboxDefinition
|
||||
title?: SandboxLocalizedText
|
||||
}) {
|
||||
const rootRef = React.useRef<HTMLElement>(null)
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const filenames = React.useMemo(
|
||||
() => Object.keys(definition.files),
|
||||
[definition.files]
|
||||
)
|
||||
const [view, setView] = React.useState<SandboxView>("preview")
|
||||
const [selectedFile, setSelectedFile] = React.useState(
|
||||
definition.defaultFile ?? filenames[0] ?? ""
|
||||
)
|
||||
const [previewModule, setPreviewModule] =
|
||||
React.useState<SandboxPreviewModule | null>(null)
|
||||
const [previewError, setPreviewError] = React.useState<Error | null>(null)
|
||||
const [source, setSource] = React.useState<string | null>(null)
|
||||
const [sourceError, setSourceError] = React.useState<Error | null>(null)
|
||||
const [highlightedSource, setHighlightedSource] =
|
||||
React.useState<HighlightedSource | null>(null)
|
||||
const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1)
|
||||
const [reloadKey, setReloadKey] = React.useState(0)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [fullscreen, setFullscreen] = React.useState(false)
|
||||
const resolvedTitle = resolveSandboxText(
|
||||
title ?? definition.title,
|
||||
locale,
|
||||
definition.id
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
|
||||
const ownerDocument = root.ownerDocument
|
||||
const syncFullscreen = () => {
|
||||
setFullscreen(ownerDocument.fullscreenElement === root)
|
||||
}
|
||||
|
||||
syncFullscreen()
|
||||
ownerDocument.addEventListener("fullscreenchange", syncFullscreen)
|
||||
return () => {
|
||||
ownerDocument.removeEventListener("fullscreenchange", syncFullscreen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
setPreviewModule(null)
|
||||
setPreviewError(null)
|
||||
|
||||
definition.entry().then(
|
||||
(module) => {
|
||||
if (active) setPreviewModule(module)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (active) setPreviewError(toError(error))
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [definition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (view !== "code") return
|
||||
|
||||
const file = definition.files[selectedFile]
|
||||
if (!file) {
|
||||
setSource(null)
|
||||
setSourceError(new Error(`Source file not found: ${selectedFile}`))
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setSource(null)
|
||||
setSourceError(null)
|
||||
setHighlightedSource(null)
|
||||
resolveSource(file).then(
|
||||
(value) => {
|
||||
if (active) setSource(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (active) setSourceError(toError(error))
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [definition.files, selectedFile, view])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!source || view !== "code") return
|
||||
|
||||
let active = true
|
||||
setHighlightedSource(null)
|
||||
highlightSource(source, selectedFile).then(
|
||||
(value) => {
|
||||
if (active) setHighlightedSource(value)
|
||||
},
|
||||
() => {
|
||||
if (active) setHighlightedSource(null)
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedFile, source, view])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedFile(definition.defaultFile ?? filenames[0] ?? "")
|
||||
}, [definition.defaultFile, filenames])
|
||||
|
||||
const copySource = async () => {
|
||||
if (!source) return
|
||||
await navigator.clipboard.writeText(source)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
|
||||
const ownerDocument = root.ownerDocument
|
||||
if (ownerDocument.fullscreenElement === root) {
|
||||
await ownerDocument.exitFullscreen()
|
||||
return
|
||||
}
|
||||
|
||||
if (ownerDocument.fullscreenElement) {
|
||||
await ownerDocument.exitFullscreen()
|
||||
}
|
||||
await root.requestFullscreen()
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={rootRef}
|
||||
className={`not-prose overflow-hidden border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 ${fullscreen ? "m-0 flex h-dvh w-dvw flex-col rounded-none border-0" : "my-6 rounded-xl"} ${className ?? ""}`}
|
||||
aria-label={resolvedTitle}
|
||||
>
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-2 py-1.5 dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<div className="inline-flex rounded-lg bg-slate-200/70 p-0.5 dark:bg-slate-800">
|
||||
<SandboxTab
|
||||
active={view === "preview"}
|
||||
onClick={() => setView("preview")}
|
||||
>
|
||||
<Icon name="lucide:play" className="size-3.5" />
|
||||
{t({ zh: "预览", us: "Preview" })}
|
||||
</SandboxTab>
|
||||
<SandboxTab active={view === "code"} onClick={() => setView("code")}>
|
||||
<Icon name="lucide:code-2" className="size-3.5" />
|
||||
{t({ zh: "代码", us: "Code" })}
|
||||
</SandboxTab>
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 truncate px-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{resolvedTitle}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{view === "code" ? (
|
||||
<>
|
||||
<CodeFontSizeControl
|
||||
decreaseLabel={t({ zh: "减小字号", us: "Decrease font size" })}
|
||||
increaseLabel={t({ zh: "增大字号", us: "Increase font size" })}
|
||||
index={codeFontSizeIndex}
|
||||
onIndexChange={setCodeFontSizeIndex}
|
||||
/>
|
||||
<SandboxAction
|
||||
disabled={!source}
|
||||
label={t({ zh: "复制源码", us: "Copy source" })}
|
||||
onClick={() => void copySource()}
|
||||
>
|
||||
<Icon
|
||||
name={copied ? "lucide:check" : "lucide:copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</SandboxAction>
|
||||
</>
|
||||
) : (
|
||||
<SandboxAction
|
||||
label={t({ zh: "重置预览", us: "Reset preview" })}
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
>
|
||||
<Icon name="lucide:rotate-ccw" className="size-4" />
|
||||
</SandboxAction>
|
||||
)}
|
||||
<SandboxAction
|
||||
label={t({
|
||||
zh: fullscreen ? "退出全屏" : "全屏显示",
|
||||
us: fullscreen ? "Exit fullscreen" : "Enter fullscreen",
|
||||
})}
|
||||
pressed={fullscreen}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
<Icon
|
||||
name={fullscreen ? "lucide:minimize-2" : "lucide:maximize-2"}
|
||||
className="size-4"
|
||||
/>
|
||||
</SandboxAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "preview" ? (
|
||||
<SandboxFrame
|
||||
fill={fullscreen}
|
||||
locale={locale}
|
||||
reloadKey={reloadKey}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
|
||||
{previewError ? (
|
||||
<SandboxMessage tone="error">
|
||||
{previewError.message}
|
||||
</SandboxMessage>
|
||||
) : previewModule ? (
|
||||
React.createElement(previewModule.default, {
|
||||
key: reloadKey,
|
||||
locale,
|
||||
})
|
||||
) : (
|
||||
<SandboxMessage>
|
||||
{t({ zh: "正在加载预览…", us: "Loading preview…" })}
|
||||
</SandboxMessage>
|
||||
)}
|
||||
</SandboxErrorBoundary>
|
||||
</SandboxFrame>
|
||||
) : (
|
||||
<div
|
||||
className={`grid min-h-72 ${fullscreen ? "min-h-0 flex-1 overflow-hidden" : ""} ${filenames.length > 1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`}
|
||||
>
|
||||
{filenames.length > 1 ? (
|
||||
<nav
|
||||
className="flex overflow-x-auto border-b border-slate-200 bg-slate-50 p-2 md:flex-col md:border-r md:border-b-0 dark:border-slate-800 dark:bg-slate-900/40"
|
||||
aria-label={t({ zh: "示例文件", us: "Example files" })}
|
||||
>
|
||||
{filenames.map((filename) => (
|
||||
<button
|
||||
key={filename}
|
||||
type="button"
|
||||
className="rounded-md px-2 py-1.5 text-left font-mono text-xs whitespace-nowrap text-slate-500 hover:bg-slate-200/70 hover:text-slate-950 aria-pressed:bg-indigo-100 aria-pressed:text-indigo-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:aria-pressed:bg-indigo-950 dark:aria-pressed:text-indigo-300"
|
||||
aria-pressed={selectedFile === filename}
|
||||
onClick={() => setSelectedFile(filename)}
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<pre
|
||||
className="m-0 min-w-0 overflow-auto bg-slate-50 p-4 font-mono text-slate-800 dark:bg-slate-950 dark:text-slate-100"
|
||||
style={{
|
||||
fontSize: `${codeFontSizes[codeFontSizeIndex]}px`,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<code className="block min-w-max">
|
||||
{sourceError ? (
|
||||
sourceError.message
|
||||
) : highlightedSource ? (
|
||||
<HighlightedCode source={highlightedSource} />
|
||||
) : (
|
||||
(source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSandboxText(
|
||||
value: SandboxLocalizedText | undefined,
|
||||
locale: "en-US" | "zh-Hans",
|
||||
fallback: string
|
||||
) {
|
||||
if (typeof value === "string") return value
|
||||
return value?.[locale] ?? fallback
|
||||
}
|
||||
|
||||
function CodeFontSizeControl({
|
||||
decreaseLabel,
|
||||
increaseLabel,
|
||||
index,
|
||||
onIndexChange,
|
||||
}: {
|
||||
decreaseLabel: string
|
||||
increaseLabel: string
|
||||
index: number
|
||||
onIndexChange: React.Dispatch<React.SetStateAction<number>>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-8 items-center rounded-md border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
aria-label={`${codeFontSizes[index]}px`}
|
||||
>
|
||||
<SandboxAction
|
||||
disabled={index === 0}
|
||||
label={decreaseLabel}
|
||||
onClick={() => onIndexChange((value) => Math.max(0, value - 1))}
|
||||
>
|
||||
<Icon name="lucide:minus" className="size-3.5" />
|
||||
</SandboxAction>
|
||||
<span className="w-9 text-center font-mono text-[11px] text-slate-500 tabular-nums dark:text-slate-400">
|
||||
{codeFontSizes[index]}
|
||||
</span>
|
||||
<SandboxAction
|
||||
disabled={index === codeFontSizes.length - 1}
|
||||
label={increaseLabel}
|
||||
onClick={() =>
|
||||
onIndexChange((value) =>
|
||||
Math.min(codeFontSizes.length - 1, value + 1)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="lucide:plus" className="size-3.5" />
|
||||
</SandboxAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedCode({ source }: { source: HighlightedSource }) {
|
||||
return source.map((line, lineIndex) => (
|
||||
<React.Fragment key={line.offset}>
|
||||
<span className="sandbox-code-line">
|
||||
{line.tokens.map((token) => (
|
||||
<span
|
||||
key={token.offset}
|
||||
className="sandbox-code-token"
|
||||
style={
|
||||
{
|
||||
"--sandbox-token-dark": token.dark,
|
||||
"--sandbox-token-light": token.light,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{lineIndex < source.length - 1 ? "\n" : null}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
function SandboxTab({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
children: React.ReactNode
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:text-slate-950 aria-pressed:bg-white aria-pressed:text-slate-950 aria-pressed:shadow-sm dark:text-slate-400 dark:hover:text-slate-100 dark:aria-pressed:bg-slate-700 dark:aria-pressed:text-slate-100"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SandboxAction({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
pressed,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick: VoidFunction
|
||||
pressed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-950 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SandboxMessage({
|
||||
children,
|
||||
className,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
tone?: "error" | "neutral"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-72 items-center justify-center p-6 text-sm ${tone === "error" ? "text-red-600 dark:text-red-400" : "text-slate-500 dark:text-slate-400"} ${className ?? ""}`}
|
||||
role={tone === "error" ? "alert" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSource(source: SandboxSource) {
|
||||
if (typeof source === "string") return source
|
||||
const loaded = await source()
|
||||
return typeof loaded === "string" ? loaded : loaded.default
|
||||
}
|
||||
|
||||
function toError(value: unknown) {
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
function renderSandboxError(error: Error) {
|
||||
return <SandboxMessage tone="error">{error.message}</SandboxMessage>
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
import type { DocLocale } from "../../content/types"
|
||||
|
||||
export type SandboxLocalizedText = string | Readonly<Record<DocLocale, string>>
|
||||
|
||||
export type SandboxPreviewProps = {
|
||||
locale: DocLocale
|
||||
}
|
||||
|
||||
export type SandboxPreviewModule = {
|
||||
default: ComponentType<SandboxPreviewProps>
|
||||
}
|
||||
|
||||
export type SandboxSourceModule = {
|
||||
default: string
|
||||
}
|
||||
|
||||
export type SandboxSource =
|
||||
| string
|
||||
| (() => Promise<string | SandboxSourceModule>)
|
||||
|
||||
export type SandboxDefinition = {
|
||||
defaultFile?: string
|
||||
entry: () => Promise<SandboxPreviewModule>
|
||||
files: Readonly<Record<string, SandboxSource>>
|
||||
id: string
|
||||
title?: SandboxLocalizedText
|
||||
}
|
||||
|
||||
export function defineSandbox<const Definition extends SandboxDefinition>(
|
||||
definition: Definition
|
||||
) {
|
||||
return definition
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
- key: foundations
|
||||
order: 10
|
||||
label:
|
||||
zh-Hans: 基础能力
|
||||
en-US: Foundations
|
||||
|
||||
- key: application
|
||||
order: 20
|
||||
label:
|
||||
zh-Hans: 应用构建
|
||||
en-US: Application
|
||||
|
||||
- key: content
|
||||
order: 30
|
||||
label:
|
||||
zh-Hans: 内容与数据
|
||||
en-US: Content and data
|
||||
+183
-53
@@ -7,6 +7,7 @@ import {
|
||||
type DocLocale,
|
||||
type DocLocation,
|
||||
type DocPackage,
|
||||
type DocPackageGroup,
|
||||
type DocPage,
|
||||
type DocSection,
|
||||
type DocTocItem,
|
||||
@@ -19,63 +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, media, 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: "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",
|
||||
@@ -105,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)
|
||||
@@ -241,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>,
|
||||
@@ -339,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)) {
|
||||
@@ -359,5 +489,5 @@ function readToc(value: unknown): readonly DocTocItem[] {
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type DocSearchEntry = {
|
||||
}
|
||||
|
||||
export type DocPackage = {
|
||||
group: string
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
@@ -50,6 +51,12 @@ export type DocPackage = {
|
||||
pages: readonly DocPage[]
|
||||
}
|
||||
|
||||
export type DocPackageGroup = {
|
||||
key: string
|
||||
label: string
|
||||
packages: readonly DocPackage[]
|
||||
}
|
||||
|
||||
export type DocLocation = {
|
||||
locale: DocLocale
|
||||
package: DocPackage
|
||||
|
||||
+28
-9
@@ -1,6 +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 {
|
||||
@@ -29,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,
|
||||
@@ -38,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);
|
||||
@@ -55,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);
|
||||
@@ -73,7 +82,7 @@
|
||||
|
||||
body,
|
||||
#root {
|
||||
@apply min-h-dvh;
|
||||
@apply min-h-dvh min-w-80;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -84,3 +93,13 @@
|
||||
@apply bg-indigo-200 text-indigo-950 dark:bg-indigo-500/40 dark:text-indigo-50;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.sandbox-code-token {
|
||||
color: var(--sandbox-token-light);
|
||||
}
|
||||
|
||||
.dark .sandbox-code-token {
|
||||
color: var(--sandbox-token-dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
getDocHref,
|
||||
getDocPackages,
|
||||
getDocSectionLabel,
|
||||
getDocsHomeHref,
|
||||
resolveDocLocation,
|
||||
} from "../content/registry"
|
||||
import type { DocLocation, DocPage, DocTocItem } from "../content/types"
|
||||
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
|
||||
import { Actions, SiteFooter, SiteHeader } from "../components/layout"
|
||||
import { mdxComponents } from "../components/mdx"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
|
||||
@@ -113,17 +112,12 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<SiteHeader
|
||||
data-article-sticky-header
|
||||
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
|
||||
>
|
||||
<Container className="flex size-full items-center gap-4">
|
||||
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
|
||||
<SlashLine />
|
||||
<h1 className="truncate">{docLocation.package.label}</h1>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
</header>
|
||||
subleading={<h1 className="truncate">{docLocation.package.label}</h1>}
|
||||
actions={<Actions />}
|
||||
/>
|
||||
|
||||
<section
|
||||
data-article-sticky-header
|
||||
@@ -220,7 +214,7 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
data-slot="left"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r md:block dark:border-slate-800"
|
||||
>
|
||||
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
|
||||
<Sidebar current={docLocation} />
|
||||
@@ -248,11 +242,7 @@ export default function ArticlePage({ pathname }: { pathname: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Container className="flex h-full w-full items-center">
|
||||
Copyright © 2026
|
||||
</Container>
|
||||
</footer>
|
||||
<SiteFooter className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-31
@@ -1,49 +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 capitalize sm:text-3xl">
|
||||
my-shadcn-ui
|
||||
</h1>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
<SiteHeader
|
||||
className="pt-8 pb-10 sm:pt-12 sm:pb-14 lg:pb-16"
|
||||
actions={<Actions />}
|
||||
/>
|
||||
<Container
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8"
|
||||
className="space-y-12 lg:space-y-16"
|
||||
aria-label={t({ zh: "文档导航", us: "Documentation" })}
|
||||
>
|
||||
{docPackages.map((docPackage, index) => (
|
||||
<a
|
||||
key={docPackage.key}
|
||||
href={getDocHref(docPackage)}
|
||||
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
|
||||
{packageGroups.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
aria-labelledby={`package-group-${group.key}`}
|
||||
>
|
||||
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{index > 9 ? `${index + 1}` : `0${index + 1}`}
|
||||
</div>
|
||||
<h2 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
|
||||
{docPackage.label}
|
||||
</h2>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{docPackage.description}
|
||||
</p>
|
||||
<h2
|
||||
id={`package-group-${group.key}`}
|
||||
className="mb-5 text-xl font-semibold text-slate-950 sm:mb-6 sm:text-2xl dark:text-slate-100"
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8">
|
||||
{group.packages.map((docPackage) => (
|
||||
<a
|
||||
key={docPackage.key}
|
||||
href={getDocHref(docPackage)}
|
||||
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
|
||||
>
|
||||
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{String(packageNumbers.get(docPackage.key)).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
|
||||
{docPackage.label}
|
||||
</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{docPackage.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
))}
|
||||
</Container>
|
||||
<footer className="py-8 sm:py-12">
|
||||
<Container className="text-center text-slate-500 dark:text-slate-400">
|
||||
<div>Copyright © 2026</div>
|
||||
</Container>
|
||||
</footer>
|
||||
<SiteFooter className="sm:py-12" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,7 +1,11 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { BackButton, Container, SlashLine } from "../components/layout"
|
||||
import {
|
||||
Container,
|
||||
SiteFooter,
|
||||
SiteHeader,
|
||||
} from "../components/layout"
|
||||
import { LocaleFlag } from "../components/locale-flag"
|
||||
import { SearchTrigger } from "../components/search-trigger"
|
||||
import { Swatch } from "../components/swatch"
|
||||
@@ -80,19 +84,16 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80">
|
||||
<Container className="flex h-16 items-center gap-4">
|
||||
<BackButton />
|
||||
<SlashLine />
|
||||
<SiteHeader
|
||||
subleading={
|
||||
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
|
||||
<div className="ml-auto">
|
||||
<SearchTrigger />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
}
|
||||
className="h-16 border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80"
|
||||
actions={<SearchTrigger />}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<Container className="py-8 sm:py-12">
|
||||
<Container className="pt-8 sm:pt-12">
|
||||
<section aria-labelledby="appearance-heading">
|
||||
<h2 id="appearance-heading" className="mb-4 text-xl font-semibold">
|
||||
{t(messages.appearance)}
|
||||
@@ -271,6 +272,7 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"include": [
|
||||
"src",
|
||||
"../packages/*/docs/sandboxes/**/*.ts",
|
||||
"../packages/*/docs/sandboxes/**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/components/sandbox/sandbox-env.d.ts"],
|
||||
"include": []
|
||||
}
|
||||
+43
-2
@@ -3,15 +3,17 @@ import babel from "@rolldown/plugin-babel"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { icones } from "@icones/vite"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import expressiveCode from "satteri-expressive-code"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig, type Plugin } from "vite"
|
||||
import { satteri } from "vite-plugin-satteri"
|
||||
import { parse } from "yaml"
|
||||
|
||||
const packagesDirectory = fileURLToPath(new URL("../packages", import.meta.url))
|
||||
|
||||
/**
|
||||
* Vite 默认监听 docs 根目录。现有的外部 MDX 会作为模块被监听,但新建 package
|
||||
* 或新建 MDX 文件在首次进入模块图之前不会触发 import.meta.glob 重新计算。
|
||||
* Vite 默认监听 docs 根目录。现有的外部文档与元数据会作为模块被监听,但新建
|
||||
* package、meta.yaml 或 MDX 文件在首次进入模块图之前不会触发 glob 重新计算。
|
||||
*/
|
||||
function watchPackageDocs(): Plugin {
|
||||
return {
|
||||
@@ -22,6 +24,27 @@ function watchPackageDocs(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function loadYamlModules(): Plugin {
|
||||
return {
|
||||
name: "load-yaml-modules",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
if (!/\.ya?ml(?:\?|$)/.test(id)) return
|
||||
|
||||
try {
|
||||
return {
|
||||
code: `export default ${JSON.stringify(parse(source))}`,
|
||||
map: null,
|
||||
}
|
||||
} catch (error) {
|
||||
this.error(
|
||||
`Unable to parse ${id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loadDocSearchSources(): Plugin {
|
||||
const suffix = "?docs-search-raw"
|
||||
const virtualPrefix = "\0docs-search-raw:"
|
||||
@@ -59,6 +82,7 @@ function loadDocSearchSources(): Plugin {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
watchPackageDocs(),
|
||||
loadYamlModules(),
|
||||
loadDocSearchSources(),
|
||||
satteri({
|
||||
features: {
|
||||
@@ -69,6 +93,23 @@ export default defineConfig({
|
||||
mdx: {
|
||||
jsxImportSource: "react",
|
||||
},
|
||||
hastPlugins: [
|
||||
expressiveCode({
|
||||
frames: false,
|
||||
textMarkers: false,
|
||||
themes: ["github-light", "github-dark"],
|
||||
useDarkModeMediaQuery: false,
|
||||
themeCssSelector: (theme) =>
|
||||
theme.type === "dark" ? ".dark" : ":root:not(.dark)",
|
||||
styleOverrides: {
|
||||
borderRadius: "0.75rem",
|
||||
borderWidth: "1px",
|
||||
codeFontSize: "0.875rem",
|
||||
codeLineHeight: "1.5rem",
|
||||
codePaddingInline: "1rem",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
icones({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Atomic composition
|
||||
description: Build a custom appearance surface from controlled components without the preset dialog.
|
||||
order: 1
|
||||
toc:
|
||||
- id: controlled-atoms
|
||||
title: Controlled atoms
|
||||
- id: compose-card
|
||||
title: Compose a theme card
|
||||
- id: connected-layer
|
||||
title: State bindings
|
||||
- id: live-preview
|
||||
title: Live preview
|
||||
---
|
||||
|
||||
## Controlled atoms {#controlled-atoms}
|
||||
|
||||
`ThemeModeField`, `ThemeModeOptionList`, `ColorVisionModeField`, `TextScaleField`, `TextScaleOptionList`, `MotionModeField`, `BrightnessField`, `ThemeColorPicker`, and `ThemeToggle` are driven by values and callbacks. They do not require preference context, so they can work with forms, preview tools, or another state library.
|
||||
|
||||
```tsx
|
||||
<ThemeModeField value={mode} onValueChange={setMode} />
|
||||
<ThemeModeOptionList value={mode} onValueChange={setMode} />
|
||||
<ColorVisionModeField value={colorVisionMode} onValueChange={setColorVisionMode} />
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
<TextScaleOptionList value={textScale} onValueChange={setTextScale} />
|
||||
```
|
||||
|
||||
`AppearanceOptionList` is the reusable radio list behind `TextScaleOptionList`. Give its `items` a `value`, `label`, and optional `icon` and `description` to build theme, motion, color-vision, or brightness selectors with the same presentation. It uses native radio inputs for keyboard navigation and form semantics.
|
||||
|
||||
## Compose a theme card {#compose-card}
|
||||
|
||||
`ThemeConfigCard` combines base and accent color controls while keeping the configuration owned by the caller.
|
||||
|
||||
```tsx
|
||||
<ThemeConfigCard
|
||||
config={{ baseColor, accentColor }}
|
||||
onConfigChange={setThemeColors}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
For a more granular layout, compose `baseColorSwatches`, `accentColorSwatches`, and `ThemeColorSwatchButton` directly.
|
||||
|
||||
`ThemeColorPicker` uses the labeled `pill` style by default. For tighter layouts, switch to the color-only `swatch` style with its selected indicator:
|
||||
|
||||
```tsx
|
||||
<ThemeColorPicker
|
||||
title="Accent color"
|
||||
swatches={accentColorSwatches}
|
||||
value={accentColor}
|
||||
variant="swatch"
|
||||
onValueChange={setAccentColor}
|
||||
/>
|
||||
```
|
||||
|
||||
## State bindings {#connected-layer}
|
||||
|
||||
`AppearanceSettings` is the default binding layer between the atoms and the shared `PreferencesProvider`. A custom surface can use Appearance hooks such as `useThemeMode`, `useThemeConfig`, and `useResolvedTheme` without knowing shared-preference string keys or reusing the default layout.
|
||||
|
||||
```tsx
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
## Live preview {#live-preview}
|
||||
|
||||
The documentation Sandbox can load a typed, multi-file example without embedding implementation details in MDX:
|
||||
|
||||
<Sandbox example="appearance/theme-colors" title="Theme color controls" />
|
||||
|
||||
For a single file, import the component and its raw source directly instead of creating a manifest:
|
||||
|
||||
```mdx
|
||||
import Demo from "./Demo"
|
||||
import source from "./Demo.tsx?raw"
|
||||
|
||||
<SingleFileSandbox component={Demo} filename="Demo.tsx" source={source} />
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Appearance settings dialog
|
||||
description: Compose a trigger, controller, and hotkeys into a complete appearance settings flow.
|
||||
order: 1
|
||||
toc:
|
||||
- id: controller
|
||||
title: Mount the controller
|
||||
- id: trigger
|
||||
title: Add a trigger
|
||||
- id: custom-handle
|
||||
title: Independent dialog instance
|
||||
---
|
||||
|
||||
## Mount the controller {#controller}
|
||||
|
||||
Mount one `AppearanceController` inside the application's shared `PreferencesProvider`. It composes the default settings content and registers `Mod+,` to open the dialog and `Mod+D` to toggle the theme.
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={preferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
<App />
|
||||
</PreferencesProvider>
|
||||
```
|
||||
|
||||
## Add a trigger {#trigger}
|
||||
|
||||
Place `AppearanceDialogTrigger` in a user menu, settings button, or command palette. By default it connects to the handle used by the default controller.
|
||||
|
||||
```tsx
|
||||
<AppearanceDialogTrigger render={<button type="button" />}>
|
||||
Appearance
|
||||
</AppearanceDialogTrigger>
|
||||
```
|
||||
|
||||
## Independent dialog instance {#custom-handle}
|
||||
|
||||
When a page needs an isolated appearance dialog, pass the same new handle to its trigger and controller.
|
||||
|
||||
```tsx
|
||||
const handle = createAppearanceDialogHandle()
|
||||
|
||||
<AppearanceDialogTrigger handle={handle} render={<button type="button" />}>
|
||||
Open theme settings
|
||||
</AppearanceDialogTrigger>
|
||||
<AppearanceController handle={handle} />
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Set up appearance preferences
|
||||
description: Aggregate appearance definitions and effects with the application's shared preferences.
|
||||
order: 1
|
||||
toc:
|
||||
- id: provider
|
||||
title: Aggregate preferences
|
||||
- id: effects
|
||||
title: Register effects
|
||||
- id: theme-style
|
||||
title: Inject theme variables
|
||||
- id: persistence
|
||||
title: Persist updates
|
||||
---
|
||||
|
||||
## Aggregate preferences {#provider}
|
||||
|
||||
Build one complete preference definition map at the application boundary. Spread definitions from appearance, locale, keyboard, and other feature packages into this map, then create or load the initial values.
|
||||
|
||||
Import Appearance after UI in the application stylesheet. No UI component changes are required:
|
||||
|
||||
```css
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/appearance/globals.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
...localePreferenceDefinitions,
|
||||
}
|
||||
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [
|
||||
appearancePreferenceEffect,
|
||||
localePreferenceEffect,
|
||||
] as const
|
||||
|
||||
export function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`defaultAppearancePreferences` remains available when only the appearance slice is needed, but the definition map is the preferred source when several packages are combined.
|
||||
|
||||
## Register effects {#effects}
|
||||
|
||||
`appearancePreferenceEffect` updates document classes, root attributes, and theme variables before paint, then watches operating-system theme and motion preferences after paint. Register it in the stable `effects` array passed to `PreferencesProvider`.
|
||||
|
||||
## Inject theme variables {#theme-style}
|
||||
|
||||
Use `buildThemeStyleSheet` to generate light and dark CSS variables from one base color, one accent color, and a color-vision mode. Keep the stable element ID so the appearance effect can update the same node when preferences change.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
;<style
|
||||
id={THEME_STYLE_ELEMENT_ID}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: buildThemeStyleSheet(
|
||||
{ baseColor, accentColor },
|
||||
colorVisionMode,
|
||||
brightness
|
||||
),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Assistance modes adjust chart and destructive semantic colors and set `data-ui-color-vision`. They are not color-vision simulators and do not transform photos, video, or brand assets.
|
||||
|
||||
Text scaling changes the root font size through `data-ui-text-scale`; reduced motion shortens global animation through `data-ui-motion` while preserving low-motion loading feedback; brightness adjusts theme surface variables without using `filter: brightness()`.
|
||||
|
||||
## Persist updates {#persistence}
|
||||
|
||||
The shared `onPreferenceChange` receives a typed `{ key, value }` only when a value actually changes. Forward it to a server function or cookie writer owned by the application.
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={preferences}
|
||||
onPreferenceChange={(update) => savePreference(update)}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Overview
|
||||
description: Understand the Appearance package atoms, theme data, and preference boundary.
|
||||
order: 1
|
||||
toc:
|
||||
- id: package-role
|
||||
title: Package role
|
||||
- id: atomic-components
|
||||
title: Atomic components
|
||||
- id: preferences
|
||||
title: Preference integration
|
||||
- id: hooks
|
||||
title: Hooks
|
||||
---
|
||||
|
||||
## Package role {#package-role}
|
||||
|
||||
`@workspace/appearance` owns theme mode, base color, accent color, color-vision assistance, text scale, motion, and interface brightness. It does not create its own provider or store, and it does not require changes to `@workspace/ui`. The application aggregates it with other preference packages through `@workspace/preferences`.
|
||||
|
||||
## Atomic components {#atomic-components}
|
||||
|
||||
The controlled components can be composed independently without preference context:
|
||||
|
||||
- `ThemeModeField` selects light, dark, or system mode.
|
||||
- `ColorVisionModeField` selects the standard, protan, deutan, or tritan assistance palette.
|
||||
- `TextScaleField` scales the interface to 100%, 125%, 150%, or 200%.
|
||||
- `MotionModeField` follows the system, reduces motion, or enables full motion.
|
||||
- `BrightnessField` selects dim, normal, or bright interface surfaces.
|
||||
- `ThemeColorPicker` and `ThemeColorSwatchButton` select theme colors.
|
||||
- `ThemeConfigCard` combines the base color and accent color controls.
|
||||
- `ThemeToggle` switches between light and dark themes.
|
||||
|
||||
`AppearanceSettings`, `AppearanceDialog`, and `AppearanceController` are optional composition layers built from these atoms.
|
||||
|
||||
## Preference integration {#preferences}
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
AppearanceController,
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
}
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [appearancePreferenceEffect] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Add definitions and effects from locale, keyboard, or other packages to the same objects. The application can persist every `{ key, value }` update through the shared `onPreferenceChange` and parse cookies on the server with the aggregated definitions.
|
||||
|
||||
Color-vision assistance does not filter the complete document or its images. It replaces semantic colors such as `destructive` and `chart-*`, and writes `data-ui-color-vision` to `<html>` so application components can add icons, labels, shapes, or patterns. Important information must never rely on color alone.
|
||||
|
||||
Text, motion, and brightness are also implemented entirely through root attributes and CSS variables. Once the Appearance stylesheet is imported, existing UI components, portals, and application content inherit these settings.
|
||||
|
||||
## Hooks {#hooks}
|
||||
|
||||
Applications do not need to know the string keys that Appearance uses in the shared store. The package provides a semantic hook for every preference: `useThemeMode`, `useAccentColor`, `useBaseColor`, `useColorVisionMode`, `useTextScale`, `useMotionMode`, and `useBrightness`.
|
||||
|
||||
Composition and resolution hooks support higher-level surfaces:
|
||||
|
||||
- `useThemeConfig` reads and updates base and accent colors as one configuration.
|
||||
- `useSystemTheme` and `useSystemMotion` only read operating-system settings.
|
||||
- `useResolvedTheme` and `useResolvedMotion` return the final value after resolving `system`.
|
||||
- `useToggleTheme` returns a callback that switches between explicit light and dark modes.
|
||||
- `useAppearancePreference` is the typed low-level entry point, while `useAppearancePreferences` returns the complete Appearance snapshot.
|
||||
|
||||
```tsx
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
group: foundations
|
||||
order: 20
|
||||
label: Appearance
|
||||
description:
|
||||
zh-Hans: 配置主题、颜色、文字缩放、动态效果与界面亮度。
|
||||
en-US: Configure themes, colors, text scaling, motion, and interface brightness.
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ThemeColorPicker,
|
||||
accentColorSwatches,
|
||||
type ThemeColorPickerVariant,
|
||||
} from "@workspace/appearance/components/theme-color-picker"
|
||||
import type { ThemeColorConfig } from "@workspace/appearance/theme"
|
||||
|
||||
export function AccentPicker({
|
||||
config,
|
||||
onConfigChange,
|
||||
title,
|
||||
variant,
|
||||
}: {
|
||||
config: ThemeColorConfig
|
||||
onConfigChange: (config: ThemeColorConfig) => void
|
||||
title: string
|
||||
variant?: ThemeColorPickerVariant
|
||||
}) {
|
||||
return (
|
||||
<ThemeColorPicker
|
||||
title={title}
|
||||
swatches={accentColorSwatches}
|
||||
value={config.accentColor}
|
||||
variant={variant}
|
||||
onValueChange={(accentColor) =>
|
||||
onConfigChange({ ...config, accentColor })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { messages as englishMessages } from "@workspace/appearance/locales/en-US"
|
||||
import { messages as chineseMessages } from "@workspace/appearance/locales/zh-Hans"
|
||||
import type { ThemeColorConfig } from "@workspace/appearance/theme"
|
||||
|
||||
import { AccentPicker } from "./AccentPicker"
|
||||
|
||||
const catalogs = {
|
||||
"en-US": englishMessages,
|
||||
"zh-Hans": chineseMessages,
|
||||
} as const
|
||||
|
||||
export default function ThemeColorsExample({
|
||||
locale,
|
||||
}: {
|
||||
locale: "en-US" | "zh-Hans"
|
||||
}) {
|
||||
const [config, setConfig] = React.useState<ThemeColorConfig>({
|
||||
accentColor: "indigo",
|
||||
baseColor: "neutral",
|
||||
})
|
||||
|
||||
const title = locale === "zh-Hans" ? "强调色" : "Accent color"
|
||||
|
||||
return (
|
||||
<I18nProvider catalogs={catalogs} locale={locale}>
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
||||
<AccentPicker
|
||||
config={config}
|
||||
title={title}
|
||||
onConfigChange={setConfig}
|
||||
/>
|
||||
<AccentPicker
|
||||
config={config}
|
||||
title={title}
|
||||
variant="swatch"
|
||||
onConfigChange={setConfig}
|
||||
/>
|
||||
</div>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: "appearance/theme-colors",
|
||||
title: {
|
||||
"en-US": "Controlled theme colors",
|
||||
"zh-Hans": "受控主题颜色",
|
||||
},
|
||||
entry: () => import("./App"),
|
||||
files: {
|
||||
"App.tsx": () => import("./App.tsx?raw"),
|
||||
"AccentPicker.tsx": () => import("./AccentPicker.tsx?raw"),
|
||||
},
|
||||
defaultFile: "App.tsx",
|
||||
} satisfies import("../../../../../docs/src/components/sandbox/types").SandboxDefinition
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../docs/tsconfig.sandbox.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../../docs/node_modules/.tmp/appearance-docs.tsbuildinfo"
|
||||
},
|
||||
"include": ["sandboxes/**/*.ts", "sandboxes/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: 原子化组合
|
||||
description: 不依赖预设弹窗,使用受控组件构建自己的外观界面。
|
||||
order: 1
|
||||
toc:
|
||||
- id: controlled-atoms
|
||||
title: 受控原子组件
|
||||
- id: compose-card
|
||||
title: 组合主题卡片
|
||||
- id: connected-layer
|
||||
title: 状态连接层
|
||||
- id: live-preview
|
||||
title: 在线预览
|
||||
---
|
||||
|
||||
## 受控原子组件 {#controlled-atoms}
|
||||
|
||||
`ThemeModeField`、`ThemeModeOptionList`、`ColorVisionModeField`、`TextScaleField`、`TextScaleOptionList`、`MotionModeField`、`BrightnessField`、`ThemeColorPicker` 和 `ThemeToggle` 都由 `value` 与回调驱动。它们不要求使用偏好上下文,因此可以接入表单、预览工具或其他状态库。
|
||||
|
||||
```tsx
|
||||
<ThemeModeField value={mode} onValueChange={setMode} />
|
||||
<ThemeModeOptionList value={mode} onValueChange={setMode} />
|
||||
<ColorVisionModeField value={colorVisionMode} onValueChange={setColorVisionMode} />
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
<TextScaleOptionList value={textScale} onValueChange={setTextScale} />
|
||||
```
|
||||
|
||||
`AppearanceOptionList` 提供 `TextScaleOptionList` 使用的通用单选列表。向 `items` 传入 `value`、`label`、可选 `icon` 与 `description`,即可用相同风格实现主题、动态效果、色彩辨识和亮度选择器。组件使用原生 radio,自动支持键盘导航与表单语义。
|
||||
|
||||
## 组合主题卡片 {#compose-card}
|
||||
|
||||
`ThemeConfigCard` 把基础色和强调色选择组合在一起,但仍由调用方持有配置。
|
||||
|
||||
```tsx
|
||||
<ThemeConfigCard
|
||||
config={{ baseColor, accentColor }}
|
||||
onConfigChange={setThemeColors}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
需要更细粒度的布局时,可以分别使用 `baseColorSwatches`、`accentColorSwatches` 和 `ThemeColorSwatchButton`。
|
||||
|
||||
`ThemeColorPicker` 默认使用带文字的 `pill` 样式;空间紧凑时可切换为仅显示颜色和选中标记的 `swatch` 样式:
|
||||
|
||||
```tsx
|
||||
<ThemeColorPicker
|
||||
title="强调色"
|
||||
swatches={accentColorSwatches}
|
||||
value={accentColor}
|
||||
variant="swatch"
|
||||
onValueChange={setAccentColor}
|
||||
/>
|
||||
```
|
||||
|
||||
## 状态连接层 {#connected-layer}
|
||||
|
||||
`AppearanceSettings` 是原子组件与共享 `PreferencesProvider` 之间的默认连接层。自定义界面可以使用 Appearance 提供的 `useThemeMode`、`useThemeConfig`、`useResolvedTheme` 等 hooks,而不必知道共享偏好的字符串键,也不必复用默认布局。
|
||||
|
||||
```tsx
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
```
|
||||
|
||||
## 在线预览 {#live-preview}
|
||||
|
||||
文档 Sandbox 可以加载通过类型检查的多文件示例,而不需要把实现细节内联到 MDX:
|
||||
|
||||
<Sandbox example="appearance/theme-colors" title="主题颜色控件" />
|
||||
|
||||
单文件示例不需要创建 Manifest,直接导入组件和原始源码即可:
|
||||
|
||||
```mdx
|
||||
import Demo from "./Demo"
|
||||
import source from "./Demo.tsx?raw"
|
||||
|
||||
<SingleFileSandbox component={Demo} filename="Demo.tsx" source={source} />
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: 外观设置弹窗
|
||||
description: 组合触发器、控制器和快捷键得到完整的外观设置流程。
|
||||
order: 1
|
||||
toc:
|
||||
- id: controller
|
||||
title: 挂载控制器
|
||||
- id: trigger
|
||||
title: 添加触发器
|
||||
- id: custom-handle
|
||||
title: 独立弹窗实例
|
||||
---
|
||||
|
||||
## 挂载控制器 {#controller}
|
||||
|
||||
在应用共享的 `PreferencesProvider` 内挂载一次 `AppearanceController`。它组合默认设置内容,并注册 `Mod+,` 打开弹窗和 `Mod+D` 切换主题。
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={preferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
<App />
|
||||
</PreferencesProvider>
|
||||
```
|
||||
|
||||
## 添加触发器 {#trigger}
|
||||
|
||||
`AppearanceDialogTrigger` 可以放在用户菜单、设置按钮或命令面板中。默认情况下,它会连接到默认控制器使用的 handle。
|
||||
|
||||
```tsx
|
||||
<AppearanceDialogTrigger render={<button type="button" />}>
|
||||
界面外观
|
||||
</AppearanceDialogTrigger>
|
||||
```
|
||||
|
||||
## 独立弹窗实例 {#custom-handle}
|
||||
|
||||
页面中需要隔离的外观弹窗时,为控制器和触发器传入同一个新 handle。
|
||||
|
||||
```tsx
|
||||
const handle = createAppearanceDialogHandle()
|
||||
|
||||
<AppearanceDialogTrigger handle={handle} render={<button type="button" />}>
|
||||
打开主题设置
|
||||
</AppearanceDialogTrigger>
|
||||
<AppearanceController handle={handle} />
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: 接入外观偏好
|
||||
description: 使用应用共享的偏好系统聚合外观定义与副作用。
|
||||
order: 1
|
||||
toc:
|
||||
- id: provider
|
||||
title: 聚合偏好
|
||||
- id: effects
|
||||
title: 注册副作用
|
||||
- id: theme-style
|
||||
title: 注入主题变量
|
||||
- id: persistence
|
||||
title: 持久化更新
|
||||
---
|
||||
|
||||
## 聚合偏好 {#provider}
|
||||
|
||||
在应用边界构建一份完整的偏好定义。把外观、语言、快捷键等功能 package 的 definitions 合并,再创建默认值或从服务端加载初始值。
|
||||
|
||||
在应用样式中于 UI 之后导入 Appearance,无需修改任何 UI 组件:
|
||||
|
||||
```css
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/appearance/globals.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
...localePreferenceDefinitions,
|
||||
}
|
||||
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [
|
||||
appearancePreferenceEffect,
|
||||
localePreferenceEffect,
|
||||
] as const
|
||||
|
||||
export function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
只需要外观偏好切片时仍可使用 `defaultAppearancePreferences`;聚合多个 package 时,应优先以 definitions 作为生成完整默认值的单一来源。
|
||||
|
||||
## 注册副作用 {#effects}
|
||||
|
||||
`appearancePreferenceEffect` 在浏览器绘制前更新文档类名、根节点属性和主题变量,并在绘制后监听操作系统主题与动态效果偏好。把组合后的 effect 放进传给 `PreferencesProvider` 的稳定 `effects` 数组中。
|
||||
|
||||
## 注入主题变量 {#theme-style}
|
||||
|
||||
使用 `buildThemeStyleSheet` 根据一组基础色、强调色以及色彩辨识模式生成浅色和深色 CSS 变量。保留固定的元素 ID 后,外观 effect 可以在偏好变化时更新同一个节点。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
;<style
|
||||
id={THEME_STYLE_ELEMENT_ID}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: buildThemeStyleSheet(
|
||||
{ baseColor, accentColor },
|
||||
colorVisionMode,
|
||||
brightness
|
||||
),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
辅助模式会调整需要区分的图表与破坏性语义色,并设置 `data-ui-color-vision`。它不是色觉模拟器,也不会改变照片、视频或品牌资产。
|
||||
|
||||
文字缩放通过 `data-ui-text-scale` 改变根字号;减少动态效果通过 `data-ui-motion` 缩短全局动画,并为加载状态保留低运动反馈;亮度只调整主题表面变量,不使用 `filter: brightness()`。
|
||||
|
||||
## 持久化更新 {#persistence}
|
||||
|
||||
共享的 `onPreferenceChange` 只在值实际变化时收到类型安全的 `{ key, value }`。应用可以把它交给服务端函数或 Cookie 写入器。
|
||||
|
||||
```tsx
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={preferences}
|
||||
onPreferenceChange={(update) => savePreference(update)}
|
||||
>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 概览
|
||||
description: 了解 Appearance 包的原子组件、主题数据和偏好边界。
|
||||
order: 1
|
||||
toc:
|
||||
- id: package-role
|
||||
title: 包的职责
|
||||
- id: atomic-components
|
||||
title: 原子组件
|
||||
- id: preferences
|
||||
title: 偏好集成
|
||||
- id: hooks
|
||||
title: Hooks
|
||||
---
|
||||
|
||||
## 包的职责 {#package-role}
|
||||
|
||||
`@workspace/appearance` 负责主题模式、基础色、强调色、色彩辨识、文字缩放、动态效果和界面亮度。它不创建自己的 Provider 或 store,也不要求修改 `@workspace/ui`;应用通过 `@workspace/preferences` 与其他偏好 package 统一聚合。
|
||||
|
||||
## 原子组件 {#atomic-components}
|
||||
|
||||
受控组件可以独立组合,不依赖偏好上下文:
|
||||
|
||||
- `ThemeModeField` 选择浅色、深色或跟随系统。
|
||||
- `ColorVisionModeField` 选择标准、Protan、Deutan 或 Tritan 辅助色板。
|
||||
- `TextScaleField` 将界面缩放到 100%、125%、150% 或 200%。
|
||||
- `MotionModeField` 跟随系统、减少动态效果或启用完整动效。
|
||||
- `BrightnessField` 选择柔暗、标准或明亮的界面表面。
|
||||
- `ThemeColorPicker` 与 `ThemeColorSwatchButton` 选择主题颜色。
|
||||
- `ThemeConfigCard` 组合基础色与强调色设置。
|
||||
- `ThemeToggle` 在浅色和深色主题之间切换。
|
||||
|
||||
`AppearanceSettings`、`AppearanceDialog` 与 `AppearanceController` 是建立在这些原子组件之上的可选组合层。
|
||||
|
||||
## 偏好集成 {#preferences}
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
PreferencesProvider,
|
||||
} from "@workspace/preferences"
|
||||
import {
|
||||
AppearanceController,
|
||||
appearancePreferenceDefinitions,
|
||||
appearancePreferenceEffect,
|
||||
} from "@workspace/appearance"
|
||||
|
||||
const preferenceDefinitions = {
|
||||
...appearancePreferenceDefinitions,
|
||||
}
|
||||
const initialPreferences = createDefaultPreferences(preferenceDefinitions)
|
||||
const preferenceEffects = [appearancePreferenceEffect] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={preferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
>
|
||||
<AppearanceController />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
把语言、快捷键等 package 的 definitions 和 effects 添加到相同对象即可。应用可以通过共享的 `onPreferenceChange` 持久化所有 `{ key, value }` 更新,并在服务端使用聚合后的 definitions 解析 Cookie。
|
||||
|
||||
色彩辨识辅助不会给整个页面或图片添加滤镜。它只替换 `destructive` 与 `chart-*` 等语义颜色,并在 `<html>` 上写入 `data-ui-color-vision`,供业务组件添加图标、文字、形状或纹理。关键信息不能只靠颜色表达。
|
||||
|
||||
文字、动态效果和亮度同样只通过根节点属性与 CSS 变量实现。导入 Appearance 样式后,现有 UI 组件、Portal 和应用内容都会继承这些设置。
|
||||
|
||||
## Hooks {#hooks}
|
||||
|
||||
应用不需要知道 Appearance 在共享 store 中使用的字符串键。Package 为每个偏好提供了语义化 hook:`useThemeMode`、`useAccentColor`、`useBaseColor`、`useColorVisionMode`、`useTextScale`、`useMotionMode` 和 `useBrightness`。
|
||||
|
||||
组合和解析类 hook 用于更高层的界面:
|
||||
|
||||
- `useThemeConfig` 将基础色和强调色作为一个配置读写。
|
||||
- `useSystemTheme` 与 `useSystemMotion` 只读取操作系统设置。
|
||||
- `useResolvedTheme` 与 `useResolvedMotion` 解析 `system` 后返回最终值。
|
||||
- `useToggleTheme` 返回在显式浅色与深色之间切换的回调。
|
||||
- `useAppearancePreference` 提供类型安全的底层入口,`useAppearancePreferences` 返回 Appearance 所有偏好的快照。
|
||||
|
||||
```tsx
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@workspace/appearance",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./color-vision": "./src/color-vision.ts",
|
||||
"./components/appearance-option-list": "./src/components/option-lists/appearance-option-list.tsx",
|
||||
"./components/brightness-field": "./src/components/fields/brightness-field.tsx",
|
||||
"./components/color-vision-mode-field": "./src/components/fields/color-vision-mode-field.tsx",
|
||||
"./components/motion-mode-field": "./src/components/fields/motion-mode-field.tsx",
|
||||
"./components/text-scale-field": "./src/components/fields/text-scale-field.tsx",
|
||||
"./components/text-scale-option-list": "./src/components/option-lists/text-scale-option-list.tsx",
|
||||
"./components/theme-color-picker": "./src/components/theme/theme-color-picker.tsx",
|
||||
"./components/theme-config-card": "./src/components/theme/theme-config-card.tsx",
|
||||
"./components/theme-mode-field": "./src/components/fields/theme-mode-field.tsx",
|
||||
"./components/theme-mode-option-list": "./src/components/option-lists/theme-mode-option-list.tsx",
|
||||
"./components/theme-toggle-button": "./src/components/theme/theme-toggle-button.tsx",
|
||||
"./components/fields": "./src/components/fields/index.ts",
|
||||
"./components/fields/*": "./src/components/fields/*.tsx",
|
||||
"./components/option-lists": "./src/components/option-lists/index.ts",
|
||||
"./components/option-lists/*": "./src/components/option-lists/*.tsx",
|
||||
"./components/theme": "./src/components/theme/index.ts",
|
||||
"./components/theme/*": "./src/components/theme/*.tsx",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./display": "./src/display.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./locales": "./src/locales/catalogs.ts",
|
||||
"./locales/*": "./src/locales/*.ts",
|
||||
"./preferences": "./src/preferences.ts",
|
||||
"./theme": "./src/theme.ts",
|
||||
"./use-resolved-theme": "./src/hooks/use-resolved-theme.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
/**
|
||||
* Color-vision assistance modes.
|
||||
*
|
||||
* These modes select palettes that are easier to distinguish. They are not
|
||||
* simulations of how another person sees the interface and intentionally do
|
||||
* not apply a filter to images or the complete document.
|
||||
*/
|
||||
export type ColorVisionMode = "standard" | "protan" | "deutan" | "tritan"
|
||||
|
||||
export const colorVisionModes = [
|
||||
"standard",
|
||||
"protan",
|
||||
"deutan",
|
||||
"tritan",
|
||||
] as const satisfies readonly ColorVisionMode[]
|
||||
|
||||
export const COLOR_VISION_ATTRIBUTE = "data-ui-color-vision"
|
||||
|
||||
type ColorVisionVariables = Readonly<
|
||||
Record<
|
||||
"destructive" | "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5",
|
||||
string
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Palettes for semantic data colors that commonly need to be distinguished.
|
||||
* Components must still pair color with text, icons, shapes, or patterns.
|
||||
*/
|
||||
export const colorVisionPalettes: Readonly<
|
||||
Record<
|
||||
Exclude<ColorVisionMode, "standard">,
|
||||
Record<ThemeScheme, ColorVisionVariables>
|
||||
>
|
||||
> = {
|
||||
protan: {
|
||||
light: {
|
||||
destructive: "#b54a00",
|
||||
"chart-1": "#0072b2",
|
||||
"chart-2": "#e69f00",
|
||||
"chart-3": "#009e73",
|
||||
"chart-4": "#cc79a7",
|
||||
"chart-5": "#6b5ca5",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#ff9d66",
|
||||
"chart-1": "#56b4e9",
|
||||
"chart-2": "#f0e442",
|
||||
"chart-3": "#65d5b3",
|
||||
"chart-4": "#e8a4ce",
|
||||
"chart-5": "#afa4e8",
|
||||
},
|
||||
},
|
||||
deutan: {
|
||||
light: {
|
||||
destructive: "#a64b00",
|
||||
"chart-1": "#005f9e",
|
||||
"chart-2": "#d88c00",
|
||||
"chart-3": "#8a6f00",
|
||||
"chart-4": "#bd63a0",
|
||||
"chart-5": "#168c9e",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#ff9d66",
|
||||
"chart-1": "#65bff0",
|
||||
"chart-2": "#f3c969",
|
||||
"chart-3": "#d7c85d",
|
||||
"chart-4": "#e5a0cf",
|
||||
"chart-5": "#63c7d5",
|
||||
},
|
||||
},
|
||||
tritan: {
|
||||
light: {
|
||||
destructive: "#b83261",
|
||||
"chart-1": "#b83280",
|
||||
"chart-2": "#008f72",
|
||||
"chart-3": "#d55e00",
|
||||
"chart-4": "#6b5ca5",
|
||||
"chart-5": "#53606d",
|
||||
},
|
||||
dark: {
|
||||
destructive: "#f27ca5",
|
||||
"chart-1": "#ed8bc9",
|
||||
"chart-2": "#62d1b3",
|
||||
"chart-3": "#ff9d66",
|
||||
"chart-4": "#afa4e8",
|
||||
"chart-5": "#b8c2cc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function isColorVisionMode(value: unknown): value is ColorVisionMode {
|
||||
return colorVisionModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function getColorVisionCssVariables(
|
||||
mode: ColorVisionMode,
|
||||
scheme: ThemeScheme
|
||||
): ColorVisionVariables | undefined {
|
||||
return mode === "standard" ? undefined : colorVisionPalettes[mode][scheme]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { messages as englishMessages } from "../locales/en-US"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { TextScaleOptionList } from "./option-lists/text-scale-option-list"
|
||||
import { ThemeModeOptionList } from "./option-lists/theme-mode-option-list"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme/theme-color-picker"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderLocalized(node: React.ReactNode) {
|
||||
return render(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
{node}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function selectOption(triggerName: string, optionName: string) {
|
||||
fireEvent.click(screen.getByRole("combobox", { name: triggerName }))
|
||||
const option = screen.getByRole("option", { name: optionName })
|
||||
fireEvent.pointerDown(option, { pointerType: "mouse" })
|
||||
fireEvent.click(option, { detail: 1 })
|
||||
}
|
||||
|
||||
describe("appearance atoms", () => {
|
||||
it("reports color-vision mode changes without owning state", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ColorVisionModeField value="standard" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
selectOption("Color-vision assistance", "Green-weak assistance (deutan)")
|
||||
expect(onValueChange).toHaveBeenCalledWith("deutan")
|
||||
})
|
||||
|
||||
it("reports text scale, motion, and brightness changes", () => {
|
||||
const setTextScale = vi.fn()
|
||||
const setMotionMode = vi.fn()
|
||||
const setBrightness = vi.fn()
|
||||
|
||||
const { rerender } = renderLocalized(
|
||||
<TextScaleField value="100" onValueChange={setTextScale} />
|
||||
)
|
||||
selectOption("Text size", "150%")
|
||||
expect(setTextScale).toHaveBeenCalledWith("150")
|
||||
|
||||
rerender(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
<MotionModeField value="system" onValueChange={setMotionMode} />
|
||||
</I18nProvider>
|
||||
)
|
||||
selectOption("Motion", "Reduce motion")
|
||||
expect(setMotionMode).toHaveBeenCalledWith("reduce")
|
||||
|
||||
rerender(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
<BrightnessField value="normal" onValueChange={setBrightness} />
|
||||
</I18nProvider>
|
||||
)
|
||||
selectOption("Brightness", "Dim")
|
||||
expect(setBrightness).toHaveBeenCalledWith("dim")
|
||||
})
|
||||
|
||||
it("provides a radio-based text scale option list", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<TextScaleOptionList value="125" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole("group", { name: "Text size" })).not.toBeNull()
|
||||
expect(
|
||||
(screen.getByRole("radio", { name: "125%" }) as HTMLInputElement).checked
|
||||
).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "200%" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("200")
|
||||
})
|
||||
|
||||
it("provides a radio-based theme mode option list", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeModeOptionList value="system" onValueChange={onValueChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole("group", { name: "Theme mode" })).not.toBeNull()
|
||||
expect(
|
||||
(screen.getByRole("radio", { name: "System" }) as HTMLInputElement)
|
||||
.checked
|
||||
).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Dark" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("dark")
|
||||
})
|
||||
|
||||
it("reports the selected theme color", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeColorPicker
|
||||
onValueChange={onValueChange}
|
||||
swatches={accentColorSwatches}
|
||||
title="Primary color"
|
||||
value="blue"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("rose")
|
||||
})
|
||||
|
||||
it("renders compact color swatches without visible labels", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeColorPicker
|
||||
onValueChange={onValueChange}
|
||||
swatches={accentColorSwatches}
|
||||
title="Accent color"
|
||||
value="blue"
|
||||
variant="swatch"
|
||||
/>
|
||||
)
|
||||
|
||||
const blue = screen.getByRole("button", { name: "Blue" })
|
||||
const rose = screen.getByRole("button", { name: "Rose" })
|
||||
expect(blue.getAttribute("data-variant")).toBe("swatch")
|
||||
expect(blue.getAttribute("aria-pressed")).toBe("true")
|
||||
expect(rose.textContent).toBe("")
|
||||
|
||||
fireEvent.click(rose)
|
||||
expect(onValueChange).toHaveBeenCalledWith("rose")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import { useToggleTheme } from "../hooks/use-toggle-theme"
|
||||
import {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
appearanceDialogHandle,
|
||||
type AppearanceDialogHandle,
|
||||
} from "./appearance-dialog"
|
||||
import { AppearanceHotkeys } from "./appearance-hotkeys"
|
||||
|
||||
export interface AppearanceControllerProps {
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceController({
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceControllerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
const toggleDialog = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handle.close()
|
||||
} else {
|
||||
handle.open(null)
|
||||
}
|
||||
}, [handle, isOpen])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<AppearanceDialog handle={handle} onOpenChange={handleOpenChange}>
|
||||
<AppearanceHotkeys
|
||||
onToggleDialog={toggleDialog}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<AppearanceDialogContent />
|
||||
</AppearanceDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import {
|
||||
createDialogHandle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
type DialogHandle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import { AppearanceSettings } from "./appearance-settings"
|
||||
|
||||
export type AppearanceDialogHandle = DialogHandle<void>
|
||||
|
||||
export function createAppearanceDialogHandle(): AppearanceDialogHandle {
|
||||
return createDialogHandle<void>()
|
||||
}
|
||||
|
||||
export const appearanceDialogHandle = createAppearanceDialogHandle()
|
||||
|
||||
export interface AppearanceDialogProps {
|
||||
children?: ReactNode
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceDialog({
|
||||
children,
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceDialogProps) {
|
||||
return (
|
||||
<Dialog handle={handle} onOpenChange={onOpenChange}>
|
||||
{children ?? <AppearanceDialogContent />}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceDialogHeader() {
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<LocalizedText message={appearanceMessages.appearance} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<LocalizedText message={appearanceMessages.appearanceDescription} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogContentProps extends Omit<
|
||||
ComponentProps<typeof DialogContent>,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function AppearanceDialogContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AppearanceDialogContentProps) {
|
||||
return (
|
||||
<DialogContent className={cn("sm:max-w-max", className)} {...props}>
|
||||
<AppearanceDialogHeader />
|
||||
<div className="-mx-6 max-h-[75vh] overflow-y-auto px-6">
|
||||
{children ?? <AppearanceSettings />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogTriggerProps extends Omit<
|
||||
ComponentProps<typeof DialogTrigger>,
|
||||
"handle"
|
||||
> {
|
||||
handle?: AppearanceDialogHandle
|
||||
}
|
||||
|
||||
export function AppearanceDialogTrigger({
|
||||
handle = appearanceDialogHandle,
|
||||
...props
|
||||
}: AppearanceDialogTriggerProps) {
|
||||
return <DialogTrigger handle={handle} {...props} />
|
||||
}
|
||||
|
||||
export const AppearanceTrigger = AppearanceDialogTrigger
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
|
||||
export interface AppearanceHotkeysProps {
|
||||
onToggleDialog: VoidFunction
|
||||
onToggleTheme: VoidFunction
|
||||
}
|
||||
|
||||
export function AppearanceHotkeys({
|
||||
onToggleDialog,
|
||||
onToggleTheme,
|
||||
}: AppearanceHotkeysProps) {
|
||||
const appearanceTitle = useMessage(appearanceMessages.appearance)
|
||||
const appearanceDescription = useMessage(
|
||||
appearanceMessages.appearanceCommandDescription
|
||||
)
|
||||
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
|
||||
const toggleThemeDescription = useMessage(
|
||||
appearanceMessages.toggleThemeDescription
|
||||
)
|
||||
|
||||
useHotkey("Mod+,", onToggleDialog, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: appearanceTitle,
|
||||
description: appearanceDescription,
|
||||
},
|
||||
})
|
||||
|
||||
useHotkey("Mod+D", onToggleTheme, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: toggleThemeTitle,
|
||||
description: toggleThemeDescription,
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import {
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "../hooks/use-appearance-preference"
|
||||
import { useResolvedTheme } from "../hooks/use-resolved-theme"
|
||||
import { BrightnessField } from "./fields/brightness-field"
|
||||
import { ColorVisionModeField } from "./fields/color-vision-mode-field"
|
||||
import { MotionModeField } from "./fields/motion-mode-field"
|
||||
import { TextScaleField } from "./fields/text-scale-field"
|
||||
import { ThemeModeField } from "./fields/theme-mode-field"
|
||||
import { ThemeConfigCard } from "./theme/theme-config-card"
|
||||
|
||||
export interface AppearanceSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
|
||||
const [themeMode, setThemeMode] = useThemeMode()
|
||||
const [themeConfig, setThemeConfig] = useThemeConfig()
|
||||
const [brightness, setBrightness] = useBrightness()
|
||||
const [colorVisionMode, setColorVisionMode] = useColorVisionMode()
|
||||
const [motionMode, setMotionMode] = useMotionMode()
|
||||
const [textScale, setTextScale] = useTextScale()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-10", className)}>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ThemeModeField value={themeMode} onValueChange={setThemeMode} />
|
||||
<ColorVisionModeField
|
||||
value={colorVisionMode}
|
||||
onValueChange={setColorVisionMode}
|
||||
/>
|
||||
<TextScaleField value={textScale} onValueChange={setTextScale} />
|
||||
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
|
||||
<BrightnessField value={brightness} onValueChange={setBrightness} />
|
||||
</div>
|
||||
<ThemeConfigCard
|
||||
config={themeConfig}
|
||||
onConfigChange={setThemeConfig}
|
||||
scheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { SunIcon } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { Brightness } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface BrightnessFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: Brightness) => void
|
||||
value: Brightness
|
||||
}
|
||||
|
||||
export function BrightnessField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: BrightnessFieldProps) {
|
||||
const title = useMessage(appearanceMessages.brightness)
|
||||
const dim = useMessage(appearanceMessages.brightnessDim)
|
||||
const normal = useMessage(appearanceMessages.brightnessNormal)
|
||||
const bright = useMessage(appearanceMessages.brightnessBright)
|
||||
const description = useMessage(appearanceMessages.brightnessDescription)
|
||||
const items: ReadonlyArray<{ label: string; value: Brightness }> = [
|
||||
{ label: dim, value: "dim" },
|
||||
{ label: normal, value: "normal" },
|
||||
{ label: bright, value: "bright" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(brightness) => {
|
||||
if (brightness) onValueChange(brightness)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<SunIcon />
|
||||
<SelectValue>
|
||||
{(selectedValue: Brightness | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { EyeIcon } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { ColorVisionMode } from "../../color-vision"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface ColorVisionModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: ColorVisionMode) => void
|
||||
value: ColorVisionMode
|
||||
}
|
||||
|
||||
export function ColorVisionModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ColorVisionModeFieldProps) {
|
||||
const title = useMessage(appearanceMessages.colorVisionMode)
|
||||
const standard = useMessage(appearanceMessages.colorVisionStandard)
|
||||
const protan = useMessage(appearanceMessages.colorVisionProtan)
|
||||
const deutan = useMessage(appearanceMessages.colorVisionDeutan)
|
||||
const tritan = useMessage(appearanceMessages.colorVisionTritan)
|
||||
const standardDescription = useMessage(
|
||||
appearanceMessages.colorVisionStandardDescription
|
||||
)
|
||||
const assistedDescription = useMessage(
|
||||
appearanceMessages.colorVisionAssistedDescription
|
||||
)
|
||||
const items: ReadonlyArray<{ label: string; value: ColorVisionMode }> = [
|
||||
{ label: standard, value: "standard" },
|
||||
{ label: protan, value: "protan" },
|
||||
{ label: deutan, value: "deutan" },
|
||||
{ label: tritan, value: "tritan" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{value === "standard" ? standardDescription : assistedDescription}
|
||||
</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
|
||||
<EyeIcon />
|
||||
<SelectValue>
|
||||
{(selectedValue: ColorVisionMode | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { BrightnessField, type BrightnessFieldProps } from "./brightness-field"
|
||||
export {
|
||||
ColorVisionModeField,
|
||||
type ColorVisionModeFieldProps,
|
||||
} from "./color-vision-mode-field"
|
||||
export { MotionModeField, type MotionModeFieldProps } from "./motion-mode-field"
|
||||
export { TextScaleField, type TextScaleFieldProps } from "./text-scale-field"
|
||||
export {
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./theme-mode-field"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { MotionMode } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface MotionModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: MotionMode) => void
|
||||
value: MotionMode
|
||||
}
|
||||
|
||||
export function MotionModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: MotionModeFieldProps) {
|
||||
const title = useMessage(appearanceMessages.motionMode)
|
||||
const system = useMessage(appearanceMessages.motionSystem)
|
||||
const reduce = useMessage(appearanceMessages.motionReduce)
|
||||
const full = useMessage(appearanceMessages.motionFull)
|
||||
const descriptions = {
|
||||
full: useMessage(appearanceMessages.motionFullDescription),
|
||||
reduce: useMessage(appearanceMessages.motionReduceDescription),
|
||||
system: useMessage(appearanceMessages.motionSystemDescription),
|
||||
}
|
||||
const items: ReadonlyArray<{ label: string; value: MotionMode }> = [
|
||||
{ label: system, value: "system" },
|
||||
{ label: reduce, value: "reduce" },
|
||||
{ label: full, value: "full" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{descriptions[value]}
|
||||
</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
|
||||
<SelectValue>
|
||||
{(selectedValue: MotionMode | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { ALargeSmall } from "@workspace/ui/components/icon"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
|
||||
export interface TextScaleFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: TextScale) => void
|
||||
value: TextScale
|
||||
}
|
||||
|
||||
export function TextScaleField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: TextScaleFieldProps) {
|
||||
const title = useMessage(appearanceMessages.textScale)
|
||||
const description = useMessage(appearanceMessages.textScaleDescription)
|
||||
const items: ReadonlyArray<{ label: string; value: TextScale }> = [
|
||||
{ label: "100%", value: "100" },
|
||||
{ label: "125%", value: "125" },
|
||||
{ label: "150%", value: "150" },
|
||||
{ label: "200%", value: "200" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(scale) => {
|
||||
if (scale) onValueChange(scale)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<ALargeSmall />
|
||||
<SelectValue>
|
||||
{(selectedValue: TextScale | null) =>
|
||||
items.find((item) => item.value === selectedValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
|
||||
export interface ThemeModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: ThemeMode) => void
|
||||
value: ThemeMode
|
||||
}
|
||||
|
||||
export function ThemeModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
if (mode === "light") return <SunIcon />
|
||||
if (mode === "dark") return <MoonIcon />
|
||||
return <MonitorIcon />
|
||||
}
|
||||
|
||||
export function ThemeModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ThemeModeFieldProps) {
|
||||
const lightLabel = useMessage(appearanceMessages.lightMode)
|
||||
const darkLabel = useMessage(appearanceMessages.darkMode)
|
||||
const systemLabel = useMessage(appearanceMessages.followSystem)
|
||||
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
|
||||
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
|
||||
const systemDescription = useMessage(
|
||||
appearanceMessages.followSystemDescription
|
||||
)
|
||||
const title = useMessage(appearanceMessages.themeMode)
|
||||
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
|
||||
{ label: lightLabel, value: "light" },
|
||||
{ label: darkLabel, value: "dark" },
|
||||
{ label: systemLabel, value: "system" },
|
||||
]
|
||||
const description = {
|
||||
light: lightDescription,
|
||||
dark: darkDescription,
|
||||
system: systemDescription,
|
||||
}[value]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={modeItems}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
|
||||
<SelectValue>
|
||||
{(selectedValue: ThemeMode | null) => (
|
||||
<>
|
||||
<ThemeModeIcon mode={selectedValue} />
|
||||
{modeItems.find((item) => item.value === selectedValue)?.label}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{modeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<ThemeModeIcon mode={item.value} />
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
export interface AppearanceOption<TValue extends string> {
|
||||
description?: ReactNode
|
||||
disabled?: boolean
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
value: TValue
|
||||
}
|
||||
|
||||
export interface AppearanceOptionListProps<TValue extends string> extends Omit<
|
||||
ComponentProps<"fieldset">,
|
||||
"onChange"
|
||||
> {
|
||||
items: readonly AppearanceOption<TValue>[]
|
||||
label: ReactNode
|
||||
name?: string
|
||||
onValueChange: (value: TValue) => void
|
||||
value: TValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, accessible option list shared by appearance preferences.
|
||||
* Native radio inputs provide keyboard navigation and form semantics while the
|
||||
* labels supply the larger visual target.
|
||||
*/
|
||||
export function AppearanceOptionList<TValue extends string>({
|
||||
className,
|
||||
disabled,
|
||||
items,
|
||||
label,
|
||||
name,
|
||||
onValueChange,
|
||||
value,
|
||||
...props
|
||||
}: AppearanceOptionListProps<TValue>) {
|
||||
const generatedName = useId()
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="appearance-option-list"
|
||||
className={cn(
|
||||
"min-w-0 space-y-1 rounded-xl border p-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
<legend className="sr-only">{label}</legend>
|
||||
{items.map((item) => {
|
||||
const checked = item.value === value
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.value}
|
||||
data-checked={checked}
|
||||
data-disabled={item.disabled || undefined}
|
||||
className={cn(
|
||||
"relative grid min-h-12 cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 py-2 transition-colors outline-none",
|
||||
"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
|
||||
checked
|
||||
? "bg-accent/12 text-accent dark:bg-accent/20"
|
||||
: "text-foreground hover:bg-highlight hover:text-highlight-foreground",
|
||||
item.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="peer sr-only"
|
||||
checked={checked}
|
||||
disabled={item.disabled}
|
||||
name={name ?? generatedName}
|
||||
value={item.value}
|
||||
onChange={() => onValueChange(item.value)}
|
||||
/>
|
||||
<span
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center [&_svg]:size-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description == null ? null : (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 block text-xs",
|
||||
checked ? "text-accent/80" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-opacity",
|
||||
checked ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
} from "./appearance-option-list"
|
||||
export {
|
||||
TextScaleOptionList,
|
||||
type TextScaleOptionListProps,
|
||||
} from "./text-scale-option-list"
|
||||
export {
|
||||
ThemeModeOptionList,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./theme-mode-option-list"
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type { TextScale } from "../../display"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
} from "./appearance-option-list"
|
||||
|
||||
const textScaleItems = [
|
||||
{
|
||||
icon: <span className="text-xs font-semibold">A</span>,
|
||||
label: "100%",
|
||||
value: "100",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-sm font-semibold">A</span>,
|
||||
label: "125%",
|
||||
value: "125",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-base font-semibold">A</span>,
|
||||
label: "150%",
|
||||
value: "150",
|
||||
},
|
||||
{
|
||||
icon: <span className="text-lg font-semibold">A</span>,
|
||||
label: "200%",
|
||||
value: "200",
|
||||
},
|
||||
] as const satisfies readonly AppearanceOption<TextScale>[]
|
||||
|
||||
export interface TextScaleOptionListProps {
|
||||
className?: string
|
||||
onValueChange: (value: TextScale) => void
|
||||
value: TextScale
|
||||
}
|
||||
|
||||
export function TextScaleOptionList({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: TextScaleOptionListProps) {
|
||||
const title = useMessage(appearanceMessages.textScale)
|
||||
const description = useMessage(appearanceMessages.textScaleDescription)
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<AppearanceOptionList
|
||||
className="mt-2 w-full max-w-64"
|
||||
items={textScaleItems}
|
||||
label={title}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeMode } from "../../preferences"
|
||||
import {
|
||||
AppearanceOptionList,
|
||||
type AppearanceOption,
|
||||
} from "./appearance-option-list"
|
||||
|
||||
export interface ThemeModeOptionListProps {
|
||||
className?: string
|
||||
onValueChange: (value: ThemeMode) => void
|
||||
value: ThemeMode
|
||||
}
|
||||
|
||||
export function ThemeModeOptionList({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ThemeModeOptionListProps) {
|
||||
const title = useMessage(appearanceMessages.themeMode)
|
||||
const lightLabel = useMessage(appearanceMessages.lightMode)
|
||||
const darkLabel = useMessage(appearanceMessages.darkMode)
|
||||
const systemLabel = useMessage(appearanceMessages.followSystem)
|
||||
const descriptions: Record<ThemeMode, string> = {
|
||||
light: useMessage(appearanceMessages.lightModeDescription),
|
||||
dark: useMessage(appearanceMessages.darkModeDescription),
|
||||
system: useMessage(appearanceMessages.followSystemDescription),
|
||||
}
|
||||
const items: readonly AppearanceOption<ThemeMode>[] = [
|
||||
{ icon: <SunIcon />, label: lightLabel, value: "light" },
|
||||
{ icon: <MoonIcon />, label: darkLabel, value: "dark" },
|
||||
{ icon: <MonitorIcon />, label: systemLabel, value: "system" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{descriptions[value]}
|
||||
</p>
|
||||
<AppearanceOptionList
|
||||
className="mt-2 w-full max-w-64"
|
||||
items={items}
|
||||
label={title}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorPickerVariant,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
} from "./theme-color-picker"
|
||||
export { ThemeConfigCard, type ThemeConfigCardProps } from "./theme-config-card"
|
||||
export {
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./theme-toggle-button"
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { CheckIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { themeColorMessages } from "../../messages"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
getTheme,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
} from "../../theme"
|
||||
|
||||
export type ThemeColor = BaseColor | AccentColor
|
||||
export type ThemeColorPickerVariant = "pill" | "swatch"
|
||||
|
||||
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
|
||||
dark: ThemeColorSwatchScheme
|
||||
light: ThemeColorSwatchScheme
|
||||
name: T
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchScheme {
|
||||
background: string
|
||||
foreground: string
|
||||
}
|
||||
|
||||
export function createThemeColorSwatches<T extends ThemeColor>(
|
||||
colors: readonly T[]
|
||||
): ThemeColorSwatch<T>[] {
|
||||
return colors.map((name) => {
|
||||
const theme = getTheme(name)!
|
||||
|
||||
return {
|
||||
name,
|
||||
light: {
|
||||
background: theme.cssVars.light.accent,
|
||||
foreground: theme.cssVars.light["accent-foreground"],
|
||||
},
|
||||
dark: {
|
||||
background: theme.cssVars.dark.accent,
|
||||
foreground: theme.cssVars.dark["accent-foreground"],
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const baseColorSwatches = createThemeColorSwatches(baseColors)
|
||||
export const accentColorSwatches = createThemeColorSwatches(accentColors)
|
||||
|
||||
export interface ThemeColorPickerProps<T extends ThemeColor> {
|
||||
className?: string
|
||||
onValueChange: (color: T) => void
|
||||
swatches: readonly ThemeColorSwatch<T>[]
|
||||
title: ReactNode
|
||||
value: T
|
||||
variant?: ThemeColorPickerVariant
|
||||
}
|
||||
|
||||
export function ThemeColorPicker<T extends ThemeColor>({
|
||||
className,
|
||||
onValueChange,
|
||||
swatches,
|
||||
title,
|
||||
value,
|
||||
variant = "pill",
|
||||
}: ThemeColorPickerProps<T>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="theme-color-picker"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
variant === "pill" ? "relative rounded-lg border p-4" : "border-0 p-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<legend
|
||||
className={cn(
|
||||
variant === "pill"
|
||||
? "absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground"
|
||||
: "mb-3 px-0 text-sm font-medium text-foreground"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</legend>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center",
|
||||
variant === "pill" ? "gap-2" : "gap-3"
|
||||
)}
|
||||
>
|
||||
{swatches.map((swatch) => (
|
||||
<ThemeColorSwatchButton
|
||||
key={swatch.name}
|
||||
swatch={swatch}
|
||||
pressed={value === swatch.name}
|
||||
variant={variant}
|
||||
onClick={() => onValueChange(swatch.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchButtonProps {
|
||||
onClick: VoidFunction
|
||||
pressed: boolean
|
||||
swatch: ThemeColorSwatch
|
||||
variant?: ThemeColorPickerVariant
|
||||
}
|
||||
|
||||
export function ThemeColorSwatchButton({
|
||||
onClick,
|
||||
pressed,
|
||||
swatch,
|
||||
variant = "pill",
|
||||
}: ThemeColorSwatchButtonProps) {
|
||||
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
||||
const style = {
|
||||
"--light-bg": swatch.light.background,
|
||||
"--light-fg": swatch.light.foreground,
|
||||
"--dark-bg": swatch.dark.background,
|
||||
"--dark-fg": swatch.dark.foreground,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="theme-color-swatch"
|
||||
data-variant={variant}
|
||||
aria-label={swatchTitle}
|
||||
aria-pressed={pressed}
|
||||
title={variant === "swatch" ? swatchTitle : undefined}
|
||||
className={cn(
|
||||
"appearance-none bg-(--light-bg) dark:bg-(--dark-bg)",
|
||||
variant === "pill"
|
||||
? [
|
||||
"h-8 min-w-8 truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
|
||||
"border-(--light-bg) text-(--light-fg) dark:border-(--dark-bg) dark:text-(--dark-fg)",
|
||||
"ring-0 aria-pressed:ring-2",
|
||||
]
|
||||
: [
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-black/10 p-0 ring-offset-2 ring-offset-background transition-[box-shadow,transform]",
|
||||
"hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"aria-pressed:ring-2 aria-pressed:ring-(--light-bg) dark:border-white/15 dark:aria-pressed:ring-(--dark-bg)",
|
||||
]
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{variant === "pill" ? (
|
||||
swatchTitle
|
||||
) : pressed ? (
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-full bg-(--light-fg) text-(--light-bg) shadow-sm dark:bg-(--dark-fg) dark:text-(--dark-bg)">
|
||||
<CheckIcon className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeColorConfig, ThemeScheme } from "../../theme"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme-color-picker"
|
||||
|
||||
export interface ThemeConfigCardProps {
|
||||
className?: string
|
||||
config: ThemeColorConfig
|
||||
onConfigChange: (config: ThemeColorConfig) => void
|
||||
scheme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeConfigCard({
|
||||
className,
|
||||
config,
|
||||
onConfigChange,
|
||||
scheme,
|
||||
}: ThemeConfigCardProps) {
|
||||
return (
|
||||
<section className={cn("w-full max-w-md rounded-xl border", className)}>
|
||||
<header className="flex gap-4 border-b p-3">
|
||||
{scheme === "light" ? (
|
||||
<SunIcon className="size-5" />
|
||||
) : (
|
||||
<MoonIcon className="size-5" />
|
||||
)}
|
||||
<h2>
|
||||
<LocalizedText message={appearanceMessages.themeColors} />
|
||||
</h2>
|
||||
</header>
|
||||
<div className="space-y-4 p-4">
|
||||
<p>
|
||||
<LocalizedText message={appearanceMessages.themeColorsDescription} />
|
||||
</p>
|
||||
<ThemeColorPicker
|
||||
swatches={baseColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.baseColor} />}
|
||||
value={config.baseColor}
|
||||
onValueChange={(baseColor) =>
|
||||
onConfigChange({ ...config, baseColor })
|
||||
}
|
||||
/>
|
||||
<ThemeColorPicker
|
||||
swatches={accentColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.accentColor} />}
|
||||
value={config.accentColor}
|
||||
onValueChange={(accentColor) =>
|
||||
onConfigChange({ ...config, accentColor })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { useResolvedTheme } from "../../hooks/use-resolved-theme"
|
||||
import { useToggleTheme } from "../../hooks/use-toggle-theme"
|
||||
import { appearanceMessages } from "../../messages"
|
||||
import type { ThemeScheme } from "../../theme"
|
||||
|
||||
export interface ThemeToggleProps extends Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"children" | "onClick"
|
||||
> {
|
||||
onThemeChange: (theme: ThemeScheme) => void
|
||||
theme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
onThemeChange,
|
||||
theme,
|
||||
...props
|
||||
}: ThemeToggleProps) {
|
||||
const switchToDarkLabel = useMessage(appearanceMessages.switchToDark)
|
||||
const switchToLightLabel = useMessage(appearanceMessages.switchToLight)
|
||||
const nextTheme = theme === "dark" ? "light" : "dark"
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label={nextTheme === "dark" ? switchToDarkLabel : switchToLightLabel}
|
||||
className={cn(
|
||||
"rounded-lg [&_svg]:opacity-45 [&_svg]:transition-opacity hover:[&_svg]:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onClick={() => onThemeChange(nextTheme)}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ThemeToggleButtonProps = Omit<
|
||||
ThemeToggleProps,
|
||||
"onThemeChange" | "theme"
|
||||
>
|
||||
|
||||
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<ThemeToggle {...props} theme={resolvedTheme} onThemeChange={toggleTheme} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ThemeScheme } from "./internal/themes"
|
||||
|
||||
export type Brightness = "dim" | "normal" | "bright"
|
||||
export type MotionMode = "system" | "reduce" | "full"
|
||||
export type ResolvedMotionMode = Exclude<MotionMode, "system">
|
||||
export type TextScale = "100" | "125" | "150" | "200"
|
||||
|
||||
export const brightnessModes = [
|
||||
"dim",
|
||||
"normal",
|
||||
"bright",
|
||||
] as const satisfies readonly Brightness[]
|
||||
export const motionModes = [
|
||||
"system",
|
||||
"reduce",
|
||||
"full",
|
||||
] as const satisfies readonly MotionMode[]
|
||||
export const textScales = [
|
||||
"100",
|
||||
"125",
|
||||
"150",
|
||||
"200",
|
||||
] as const satisfies readonly TextScale[]
|
||||
|
||||
export const BRIGHTNESS_ATTRIBUTE = "data-ui-brightness"
|
||||
export const MOTION_ATTRIBUTE = "data-ui-motion"
|
||||
export const TEXT_SCALE_ATTRIBUTE = "data-ui-text-scale"
|
||||
|
||||
const surfaceVariableNames = [
|
||||
"background",
|
||||
"card",
|
||||
"popover",
|
||||
"secondary",
|
||||
"muted",
|
||||
"highlight",
|
||||
"sidebar",
|
||||
] as const
|
||||
|
||||
export function isBrightness(value: unknown): value is Brightness {
|
||||
return brightnessModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function isMotionMode(value: unknown): value is MotionMode {
|
||||
return motionModes.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function isTextScale(value: unknown): value is TextScale {
|
||||
return textScales.some((scale) => scale === value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts interface surfaces without filtering media, illustrations, or brand
|
||||
* colors. Foreground colors are preserved so dimming does not reduce contrast.
|
||||
*/
|
||||
export function applyBrightnessToTheme(
|
||||
variables: Record<string, string>,
|
||||
brightness: Brightness,
|
||||
scheme: ThemeScheme
|
||||
): Record<string, string> {
|
||||
if (brightness === "normal") return variables
|
||||
|
||||
const target = brightness === "dim" ? "black" : "white"
|
||||
const amount = {
|
||||
bright: scheme === "light" ? 12 : 8,
|
||||
dim: scheme === "light" ? 5 : 12,
|
||||
}[brightness]
|
||||
const adjusted = { ...variables }
|
||||
|
||||
for (const name of surfaceVariableNames) {
|
||||
const value = variables[name]
|
||||
if (value) {
|
||||
adjusted[name] = `color-mix(in oklch,${value},${target} ${amount}%)`
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider, usePreference } from "@workspace/preferences"
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import { appearancePreferenceEffect } from "./effects"
|
||||
import { defaultAppearancePreferences } from "./preferences"
|
||||
import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
|
||||
import {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
} from "./display"
|
||||
import { THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 1)
|
||||
)
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const style = document.createElement("style")
|
||||
style.id = THEME_STYLE_ELEMENT_ID
|
||||
document.head.append(style)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
|
||||
document.documentElement.className = ""
|
||||
document.documentElement.removeAttribute(COLOR_VISION_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(BRIGHTNESS_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(MOTION_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(TEXT_SCALE_ATTRIBUTE)
|
||||
document.documentElement.style.colorScheme = ""
|
||||
})
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setThemeMode("dark")}>
|
||||
{themeMode}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearancePreferenceEffect", () => {
|
||||
it("integrates with the shared PreferencesProvider", () => {
|
||||
const onPreferenceChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={defaultAppearancePreferences}
|
||||
onPreferenceChange={onPreferenceChange}
|
||||
>
|
||||
<PreferenceProbe />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:light")).toBe(true)
|
||||
expect(document.documentElement.getAttribute(COLOR_VISION_ATTRIBUTE)).toBe(
|
||||
"standard"
|
||||
)
|
||||
expect(document.documentElement.getAttribute(BRIGHTNESS_ATTRIBUTE)).toBe(
|
||||
"normal"
|
||||
)
|
||||
expect(document.documentElement.getAttribute(MOTION_ATTRIBUTE)).toBe("full")
|
||||
expect(document.documentElement.getAttribute(TEXT_SCALE_ATTRIBUTE)).toBe(
|
||||
"100"
|
||||
)
|
||||
expect(
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
|
||||
).toContain("--background:")
|
||||
|
||||
fireEvent.click(screen.getByRole("button"))
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:dark")).toBe(true)
|
||||
expect(onPreferenceChange).toHaveBeenCalledWith({
|
||||
key: "theme-mode",
|
||||
value: "dark",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
type PreferenceEffect,
|
||||
type PreferenceEffectCallback,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import type { AppearancePreferences } from "./preferences"
|
||||
import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
|
||||
import {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
} from "./display"
|
||||
import { buildThemeStyleSheet, THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
import {
|
||||
getSystemMotion,
|
||||
getSystemTheme,
|
||||
subscribeToSystemMotion,
|
||||
subscribeToSystemTheme,
|
||||
} from "./internal/system-preferences"
|
||||
const appearancePreferenceKeys = [
|
||||
"accent-color",
|
||||
"base-color",
|
||||
"brightness",
|
||||
"color-vision-mode",
|
||||
"motion-mode",
|
||||
"text-scale",
|
||||
"theme-mode",
|
||||
] as const satisfies readonly (keyof AppearancePreferences)[]
|
||||
|
||||
let restoreThemeTransitionFrame: number | undefined
|
||||
|
||||
function disableThemeTransitionsTemporarily() {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.add("ui:theme-changing")
|
||||
|
||||
if (restoreThemeTransitionFrame !== undefined) {
|
||||
cancelAnimationFrame(restoreThemeTransitionFrame)
|
||||
}
|
||||
|
||||
restoreThemeTransitionFrame = requestAnimationFrame(() => {
|
||||
restoreThemeTransitionFrame = requestAnimationFrame(() => {
|
||||
root.classList.remove("ui:theme-changing")
|
||||
restoreThemeTransitionFrame = undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function applyAppearancePreferences(preferences: AppearancePreferences) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const resolvedTheme =
|
||||
preferences["theme-mode"] === "system"
|
||||
? getSystemTheme()
|
||||
: preferences["theme-mode"]
|
||||
const resolvedMotion =
|
||||
preferences["motion-mode"] === "system"
|
||||
? getSystemMotion()
|
||||
: preferences["motion-mode"]
|
||||
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
|
||||
|
||||
if (themeStyle) {
|
||||
themeStyle.textContent = buildThemeStyleSheet(
|
||||
{
|
||||
accentColor: preferences["accent-color"],
|
||||
baseColor: preferences["base-color"],
|
||||
},
|
||||
preferences["color-vision-mode"],
|
||||
preferences.brightness
|
||||
)
|
||||
}
|
||||
|
||||
root.classList.toggle("ui:dark", resolvedTheme === "dark")
|
||||
root.classList.toggle("ui:light", resolvedTheme === "light")
|
||||
root.setAttribute(BRIGHTNESS_ATTRIBUTE, preferences.brightness)
|
||||
root.setAttribute(COLOR_VISION_ATTRIBUTE, preferences["color-vision-mode"])
|
||||
root.setAttribute(MOTION_ATTRIBUTE, resolvedMotion)
|
||||
root.setAttribute(TEXT_SCALE_ATTRIBUTE, preferences["text-scale"])
|
||||
root.style.colorScheme = resolvedTheme
|
||||
}
|
||||
|
||||
/** Applies appearance preferences before the browser paints the updated UI. */
|
||||
export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
|
||||
previousPreferences,
|
||||
preferences,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
const previousAppearancePreferences =
|
||||
previousPreferences as AppearancePreferences
|
||||
|
||||
if (
|
||||
appearancePreferenceKeys.some(
|
||||
(key) =>
|
||||
!Object.is(
|
||||
previousAppearancePreferences[key],
|
||||
appearancePreferences[key]
|
||||
)
|
||||
)
|
||||
) {
|
||||
disableThemeTransitionsTemporarily()
|
||||
}
|
||||
|
||||
applyAppearancePreferences(appearancePreferences)
|
||||
}
|
||||
|
||||
/** Keeps system-mode themes synchronized with the operating system setting. */
|
||||
export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
|
||||
preferences,
|
||||
store,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["theme-mode"] !== "system") return
|
||||
|
||||
const applySystemTheme = () => {
|
||||
disableThemeTransitionsTemporarily()
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
return subscribeToSystemTheme(applySystemTheme)
|
||||
}
|
||||
|
||||
/** Keeps system-mode motion synchronized with the operating system setting. */
|
||||
export const subscribeSystemMotionPreferenceEffect: PreferenceEffectCallback =
|
||||
({ preferences, store }) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["motion-mode"] !== "system") return
|
||||
|
||||
const applySystemMotion = () => {
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
return subscribeToSystemMotion(applySystemMotion)
|
||||
}
|
||||
|
||||
/** Subscribes to every operating-system preference used by Appearance. */
|
||||
export const subscribeSystemAppearancePreferenceEffect: PreferenceEffectCallback =
|
||||
(context) => {
|
||||
const cleanups = [
|
||||
subscribeSystemThemePreferenceEffect(context),
|
||||
subscribeSystemMotionPreferenceEffect(context),
|
||||
].filter(
|
||||
(cleanup): cleanup is VoidFunction => typeof cleanup === "function"
|
||||
)
|
||||
|
||||
if (cleanups.length === 0) return
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/** Register this effect with the application's shared `PreferencesProvider`. */
|
||||
export const appearancePreferenceEffect: PreferenceEffect = {
|
||||
layoutEffect: applyAppearancePreferenceEffect,
|
||||
effect: subscribeSystemAppearancePreferenceEffect,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider } from "@workspace/preferences"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { defaultAppearancePreferences } from "../preferences"
|
||||
import {
|
||||
useAccentColor,
|
||||
useAppearancePreferences,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
} from "./use-appearance-preference"
|
||||
import { useResolvedMotion } from "./use-resolved-motion"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
import { useToggleTheme } from "./use-toggle-theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderPreferences(node: React.ReactNode) {
|
||||
return render(
|
||||
<PreferencesProvider initialPreferences={defaultAppearancePreferences}>
|
||||
{node}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [themeMode] = useThemeMode()
|
||||
const preferences = useAppearancePreferences()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setAccentColor("rose")}>
|
||||
{accentColor}:{themeMode}:{preferences.brightness}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfigProbe() {
|
||||
const [config, setConfig] = useThemeConfig()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfig({ accentColor: "violet", baseColor: "zinc" })}
|
||||
>
|
||||
{config.accentColor}:{config.baseColor}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedPreferenceProbe() {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const resolvedMotion = useResolvedMotion()
|
||||
const toggleTheme = useToggleTheme()
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggleTheme}>
|
||||
{resolvedTheme}:{resolvedMotion}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearance hooks", () => {
|
||||
it("exposes typed preference-specific hooks and the complete snapshot", () => {
|
||||
renderPreferences(<PreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:system:normal")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("rose:system:normal")
|
||||
})
|
||||
|
||||
it("updates accent and base colors through one theme config API", () => {
|
||||
renderPreferences(<ThemeConfigProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("blue:neutral")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("violet:zinc")
|
||||
})
|
||||
|
||||
it("resolves system preferences and toggles to an explicit theme", () => {
|
||||
renderPreferences(<ResolvedPreferenceProbe />)
|
||||
|
||||
const button = screen.getByRole("button")
|
||||
expect(button.textContent).toBe("light:full")
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.textContent).toBe("dark:full")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
useAccentColor,
|
||||
useAppearancePreference,
|
||||
useAppearancePreferences,
|
||||
useBaseColor,
|
||||
useBrightness,
|
||||
useColorVisionMode,
|
||||
useMotionMode,
|
||||
useTextScale,
|
||||
useThemeConfig,
|
||||
useThemeMode,
|
||||
type AppearancePreferenceState,
|
||||
} from "./use-appearance-preference"
|
||||
export { useResolvedMotion, useSystemMotion } from "./use-resolved-motion"
|
||||
export { useResolvedTheme, useSystemTheme } from "./use-resolved-theme"
|
||||
export { useToggleTheme } from "./use-toggle-theme"
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react"
|
||||
import { usePreference, usePreferences } from "@workspace/preferences"
|
||||
|
||||
import type {
|
||||
AppearancePreferenceKey,
|
||||
AppearancePreferences,
|
||||
AppearancePreferenceValue,
|
||||
} from "../preferences"
|
||||
import type { ThemeColorConfig } from "../theme"
|
||||
|
||||
export type AppearancePreferenceState<Key extends AppearancePreferenceKey> = [
|
||||
AppearancePreferenceValue<Key>,
|
||||
React.Dispatch<React.SetStateAction<AppearancePreferenceValue<Key>>>,
|
||||
]
|
||||
|
||||
/** Reads and updates one preference owned by the Appearance package. */
|
||||
export function useAppearancePreference<Key extends AppearancePreferenceKey>(
|
||||
key: Key
|
||||
): AppearancePreferenceState<Key> {
|
||||
return usePreference(key)
|
||||
}
|
||||
|
||||
/** Returns the complete Appearance-owned portion of the shared preferences. */
|
||||
export function useAppearancePreferences(): AppearancePreferences {
|
||||
const preferences = usePreferences()
|
||||
const accentColor = preferences["accent-color"]
|
||||
const baseColor = preferences["base-color"]
|
||||
const brightness = preferences.brightness
|
||||
const colorVisionMode = preferences["color-vision-mode"]
|
||||
const motionMode = preferences["motion-mode"]
|
||||
const textScale = preferences["text-scale"]
|
||||
const themeMode = preferences["theme-mode"]
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
"accent-color": accentColor,
|
||||
"base-color": baseColor,
|
||||
brightness,
|
||||
"color-vision-mode": colorVisionMode,
|
||||
"motion-mode": motionMode,
|
||||
"text-scale": textScale,
|
||||
"theme-mode": themeMode,
|
||||
}),
|
||||
[
|
||||
accentColor,
|
||||
baseColor,
|
||||
brightness,
|
||||
colorVisionMode,
|
||||
motionMode,
|
||||
textScale,
|
||||
themeMode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function useAccentColor() {
|
||||
return useAppearancePreference("accent-color")
|
||||
}
|
||||
|
||||
export function useBaseColor() {
|
||||
return useAppearancePreference("base-color")
|
||||
}
|
||||
|
||||
export function useBrightness() {
|
||||
return useAppearancePreference("brightness")
|
||||
}
|
||||
|
||||
export function useColorVisionMode() {
|
||||
return useAppearancePreference("color-vision-mode")
|
||||
}
|
||||
|
||||
export function useMotionMode() {
|
||||
return useAppearancePreference("motion-mode")
|
||||
}
|
||||
|
||||
export function useTextScale() {
|
||||
return useAppearancePreference("text-scale")
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
return useAppearancePreference("theme-mode")
|
||||
}
|
||||
|
||||
/** Treats the two color preferences as one controlled theme configuration. */
|
||||
export function useThemeConfig(): [
|
||||
ThemeColorConfig,
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>,
|
||||
] {
|
||||
const [accentColor, setAccentColor] = useAccentColor()
|
||||
const [baseColor, setBaseColor] = useBaseColor()
|
||||
const config = React.useMemo(
|
||||
() => ({ accentColor, baseColor }),
|
||||
[accentColor, baseColor]
|
||||
)
|
||||
const configRef = React.useRef(config)
|
||||
configRef.current = config
|
||||
|
||||
const setConfig = React.useCallback<
|
||||
React.Dispatch<React.SetStateAction<ThemeColorConfig>>
|
||||
>(
|
||||
(action) => {
|
||||
const nextConfig =
|
||||
typeof action === "function" ? action(configRef.current) : action
|
||||
configRef.current = nextConfig
|
||||
setAccentColor(nextConfig.accentColor)
|
||||
setBaseColor(nextConfig.baseColor)
|
||||
},
|
||||
[setAccentColor, setBaseColor]
|
||||
)
|
||||
|
||||
return React.useMemo(() => [config, setConfig], [config, setConfig])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import {
|
||||
getServerSystemMotion,
|
||||
getSystemMotion,
|
||||
subscribeToSystemMotion,
|
||||
} from "../internal/system-preferences"
|
||||
import { useMotionMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system reduced-motion preference. */
|
||||
export function useSystemMotion(): ResolvedMotionMode {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemMotion,
|
||||
getSystemMotion,
|
||||
getServerSystemMotion
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system motion preference. */
|
||||
export function useResolvedMotion(): ResolvedMotionMode {
|
||||
const [motionMode] = useMotionMode()
|
||||
const systemMotion = useSystemMotion()
|
||||
|
||||
return motionMode === "system" ? systemMotion : motionMode
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import {
|
||||
getServerSystemTheme,
|
||||
getSystemTheme,
|
||||
subscribeToSystemTheme,
|
||||
} from "../internal/system-preferences"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
|
||||
/** Tracks the operating system color scheme independently of theme mode. */
|
||||
export function useSystemTheme(): ThemeScheme {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
getServerSystemTheme
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves `system` to the current operating system color scheme. */
|
||||
export function useResolvedTheme(): ThemeScheme {
|
||||
const [themeMode] = useThemeMode()
|
||||
const systemTheme = useSystemTheme()
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react"
|
||||
|
||||
import { useThemeMode } from "./use-appearance-preference"
|
||||
import { useResolvedTheme } from "./use-resolved-theme"
|
||||
|
||||
/** Returns a stable callback that toggles between explicit light and dark modes. */
|
||||
export function useToggleTheme(): VoidFunction {
|
||||
const [, setThemeMode] = useThemeMode()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return React.useCallback(() => {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setThemeMode])
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export {
|
||||
AppearanceController,
|
||||
type AppearanceControllerProps,
|
||||
} from "./components/appearance-controller"
|
||||
export {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
AppearanceDialogHeader,
|
||||
AppearanceDialogTrigger,
|
||||
AppearanceTrigger,
|
||||
appearanceDialogHandle,
|
||||
createAppearanceDialogHandle,
|
||||
type AppearanceDialogContentProps,
|
||||
type AppearanceDialogHandle,
|
||||
type AppearanceDialogProps,
|
||||
type AppearanceDialogTriggerProps,
|
||||
} from "./components/appearance-dialog"
|
||||
export {
|
||||
AppearanceHotkeys,
|
||||
type AppearanceHotkeysProps,
|
||||
} from "./components/appearance-hotkeys"
|
||||
export {
|
||||
AppearanceSettings,
|
||||
type AppearanceSettingsProps,
|
||||
} from "./components/appearance-settings"
|
||||
export {
|
||||
BrightnessField,
|
||||
ColorVisionModeField,
|
||||
MotionModeField,
|
||||
TextScaleField,
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type BrightnessFieldProps,
|
||||
type ColorVisionModeFieldProps,
|
||||
type MotionModeFieldProps,
|
||||
type TextScaleFieldProps,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./components/fields"
|
||||
export {
|
||||
AppearanceOptionList,
|
||||
TextScaleOptionList,
|
||||
ThemeModeOptionList,
|
||||
type AppearanceOption,
|
||||
type AppearanceOptionListProps,
|
||||
type TextScaleOptionListProps,
|
||||
type ThemeModeOptionListProps,
|
||||
} from "./components/option-lists"
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
ThemeConfigCard,
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorPickerVariant,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
type ThemeConfigCardProps,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./components/theme"
|
||||
export {
|
||||
COLOR_VISION_ATTRIBUTE,
|
||||
colorVisionModes,
|
||||
colorVisionPalettes,
|
||||
getColorVisionCssVariables,
|
||||
isColorVisionMode,
|
||||
type ColorVisionMode,
|
||||
} from "./color-vision"
|
||||
export {
|
||||
BRIGHTNESS_ATTRIBUTE,
|
||||
MOTION_ATTRIBUTE,
|
||||
TEXT_SCALE_ATTRIBUTE,
|
||||
applyBrightnessToTheme,
|
||||
brightnessModes,
|
||||
isBrightness,
|
||||
isMotionMode,
|
||||
isTextScale,
|
||||
motionModes,
|
||||
textScales,
|
||||
type Brightness,
|
||||
type MotionMode,
|
||||
type ResolvedMotionMode,
|
||||
type TextScale,
|
||||
} from "./display"
|
||||
export {
|
||||
applyAppearancePreferenceEffect,
|
||||
applyAppearancePreferences,
|
||||
appearancePreferenceEffect,
|
||||
subscribeSystemAppearancePreferenceEffect,
|
||||
subscribeSystemMotionPreferenceEffect,
|
||||
subscribeSystemThemePreferenceEffect,
|
||||
} from "./effects"
|
||||
export {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
defaultThemeColorConfig,
|
||||
isAppearancePreferenceUpdate,
|
||||
isThemeColorConfig,
|
||||
type AppearancePreferenceKey,
|
||||
type AppearancePreferences,
|
||||
type AppearancePreferenceUpdate,
|
||||
type AppearancePreferenceValue,
|
||||
type ThemeMode,
|
||||
} from "./preferences"
|
||||
export * from "./theme"
|
||||
export * from "./hooks"
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ResolvedMotionMode } from "../display"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
|
||||
export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)"
|
||||
export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
||||
|
||||
function subscribeToMediaQuery(query: string, listener: VoidFunction) {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}
|
||||
|
||||
export function subscribeToSystemTheme(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(SYSTEM_THEME_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemTheme(): ThemeScheme {
|
||||
if (typeof window === "undefined") return getServerSystemTheme()
|
||||
return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function getServerSystemTheme(): ThemeScheme {
|
||||
return "light"
|
||||
}
|
||||
|
||||
export function subscribeToSystemMotion(listener: VoidFunction) {
|
||||
return subscribeToMediaQuery(REDUCED_MOTION_QUERY, listener)
|
||||
}
|
||||
|
||||
export function getSystemMotion(): ResolvedMotionMode {
|
||||
if (typeof window === "undefined") return getServerSystemMotion()
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches ? "reduce" : "full"
|
||||
}
|
||||
|
||||
export function getServerSystemMotion(): ResolvedMotionMode {
|
||||
return "full"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages"
|
||||
import { appearanceCatalogLocales } from "./catalogs"
|
||||
import { messages as enUS } from "./en-US"
|
||||
import { messages as zhHans } from "./zh-Hans"
|
||||
|
||||
describe("appearance locale catalogs", () => {
|
||||
it("ships every message in every built-in locale", () => {
|
||||
const ids = [
|
||||
...Object.values(appearanceMessages),
|
||||
...Object.values(themeColorMessages),
|
||||
].map((descriptor) => descriptor.id)
|
||||
const catalogs = { "en-US": enUS, "zh-Hans": zhHans }
|
||||
|
||||
expect(appearanceCatalogLocales).toEqual(["en-US", "zh-Hans"])
|
||||
for (const locale of appearanceCatalogLocales) {
|
||||
expect(Object.keys(catalogs[locale])).toHaveLength(ids.length)
|
||||
expect(ids.every((id) => id in catalogs[locale])).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { appearanceMessages, themeColorMessages } from "../messages"
|
||||
|
||||
export const appearanceCatalogLocales = ["en-US", "zh-Hans"] as const
|
||||
|
||||
interface MessageDescriptorMap {
|
||||
readonly [key: string]: {
|
||||
readonly id: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
|
||||
|
||||
export type AppearanceCatalogLocale = (typeof appearanceCatalogLocales)[number]
|
||||
export type AppearanceMessageCatalog = Readonly<
|
||||
Record<
|
||||
MessageId<typeof appearanceMessages> | MessageId<typeof themeColorMessages>,
|
||||
string
|
||||
>
|
||||
>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages.ts"
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en-US"
|
||||
export const languageTag = "en-US"
|
||||
|
||||
function createSourceCatalog(
|
||||
...groups: readonly Record<string, MessageDescriptor>[]
|
||||
): AppearanceMessageCatalog {
|
||||
return Object.fromEntries(
|
||||
groups.flatMap((group) =>
|
||||
Object.values(group).map((descriptor) => [
|
||||
descriptor.id,
|
||||
descriptor.message,
|
||||
])
|
||||
)
|
||||
) as AppearanceMessageCatalog
|
||||
}
|
||||
|
||||
export const messages = createSourceCatalog(
|
||||
appearanceMessages,
|
||||
themeColorMessages
|
||||
) satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
|
||||
export const messages = {
|
||||
"appearance.accentColor": "强调色",
|
||||
"appearance.baseColor": "基础颜色",
|
||||
"appearance.brightness.bright": "明亮",
|
||||
"appearance.brightness.description": "调整界面表面,不改变图片和视频",
|
||||
"appearance.brightness.dim": "柔暗",
|
||||
"appearance.brightness.normal": "标准",
|
||||
"appearance.brightness.title": "界面亮度",
|
||||
"appearance.color.amber": "琥珀色",
|
||||
"appearance.color.blue": "蓝色",
|
||||
"appearance.color.cyan": "青色",
|
||||
"appearance.color.emerald": "翠绿色",
|
||||
"appearance.color.fuchsia": "紫红色",
|
||||
"appearance.color.green": "绿色",
|
||||
"appearance.color.indigo": "靛蓝色",
|
||||
"appearance.color.lime": "青柠色",
|
||||
"appearance.color.mauve": "紫灰",
|
||||
"appearance.color.mist": "雾灰",
|
||||
"appearance.color.neutral": "中性灰",
|
||||
"appearance.color.olive": "橄榄灰",
|
||||
"appearance.color.orange": "橙色",
|
||||
"appearance.color.pink": "粉色",
|
||||
"appearance.color.purple": "紫色",
|
||||
"appearance.color.red": "红色",
|
||||
"appearance.color.rose": "玫红色",
|
||||
"appearance.color.sky": "天蓝色",
|
||||
"appearance.color.stone": "石灰",
|
||||
"appearance.color.taupe": "灰褐",
|
||||
"appearance.color.teal": "蓝绿色",
|
||||
"appearance.color.violet": "紫罗兰色",
|
||||
"appearance.color.yellow": "黄色",
|
||||
"appearance.color.zinc": "锌灰",
|
||||
"appearance.colorVision.assisted.description":
|
||||
"使用更容易相互区分的语义色板;关键信息仍应搭配文字、图标或形状",
|
||||
"appearance.colorVision.deutan": "绿色辨识辅助(Deutan)",
|
||||
"appearance.colorVision.protan": "红色辨识辅助(Protan)",
|
||||
"appearance.colorVision.standard": "标准颜色",
|
||||
"appearance.colorVision.standard.description": "使用所选强调色和默认语义颜色",
|
||||
"appearance.colorVision.title": "色彩辨识辅助",
|
||||
"appearance.colorVision.tritan": "蓝黄色辨识辅助(Tritan)",
|
||||
"appearance.command.description": "打开外观与无障碍偏好",
|
||||
"appearance.description": "主题、颜色、文字、动态效果与界面亮度偏好",
|
||||
"appearance.mode.dark": "深色",
|
||||
"appearance.mode.dark.description": "界面将始终使用深色主题",
|
||||
"appearance.mode.light": "浅色",
|
||||
"appearance.mode.light.description": "界面将始终使用浅色主题",
|
||||
"appearance.mode.system": "系统",
|
||||
"appearance.mode.system.description": "界面主题将跟随系统外观设置",
|
||||
"appearance.mode.title": "主题模式",
|
||||
"appearance.motion.full": "完整动效",
|
||||
"appearance.motion.full.description": "允许所有界面动画和过渡效果",
|
||||
"appearance.motion.reduce": "减少动态效果",
|
||||
"appearance.motion.reduce.description": "减少非必要动画和平滑滚动",
|
||||
"appearance.motion.system": "跟随系统",
|
||||
"appearance.motion.system.description": "跟随操作系统的动态效果偏好",
|
||||
"appearance.motion.title": "动态效果",
|
||||
"appearance.textScale.description":
|
||||
"同步缩放文字、控件与间距,使内容能够重新排版",
|
||||
"appearance.textScale.title": "文字大小",
|
||||
"appearance.themeColors": "主题颜色",
|
||||
"appearance.themeColors.description": "为所有主题模式选择一组基础色和强调色",
|
||||
"appearance.title": "界面外观",
|
||||
"appearance.toggleTheme": "切换主题",
|
||||
"appearance.toggleTheme.dark": "切换为深色模式",
|
||||
"appearance.toggleTheme.description": "在浅色与深色主题之间切换",
|
||||
"appearance.toggleTheme.light": "切换为浅色模式",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const appearanceMessages = {
|
||||
accentColor: /* i18n */ {
|
||||
id: "appearance.accentColor",
|
||||
message: "Accent color",
|
||||
},
|
||||
appearance: /* i18n */ {
|
||||
id: "appearance.title",
|
||||
message: "Appearance",
|
||||
},
|
||||
appearanceCommandDescription: /* i18n */ {
|
||||
id: "appearance.command.description",
|
||||
message: "Open appearance and accessibility preferences",
|
||||
},
|
||||
baseColor: /* i18n */ {
|
||||
id: "appearance.baseColor",
|
||||
message: "Base color",
|
||||
},
|
||||
brightness: /* i18n */ {
|
||||
id: "appearance.brightness.title",
|
||||
message: "Brightness",
|
||||
},
|
||||
brightnessBright: /* i18n */ {
|
||||
id: "appearance.brightness.bright",
|
||||
message: "Bright",
|
||||
},
|
||||
brightnessDescription: /* i18n */ {
|
||||
id: "appearance.brightness.description",
|
||||
message: "Adjusts interface surfaces without changing media",
|
||||
},
|
||||
brightnessDim: /* i18n */ {
|
||||
id: "appearance.brightness.dim",
|
||||
message: "Dim",
|
||||
},
|
||||
brightnessNormal: /* i18n */ {
|
||||
id: "appearance.brightness.normal",
|
||||
message: "Normal",
|
||||
},
|
||||
colorVisionAssistedDescription: /* i18n */ {
|
||||
id: "appearance.colorVision.assisted.description",
|
||||
message: "Uses a palette with more distinguishable semantic colors",
|
||||
},
|
||||
colorVisionDeutan: /* i18n */ {
|
||||
id: "appearance.colorVision.deutan",
|
||||
message: "Green-weak assistance (deutan)",
|
||||
},
|
||||
colorVisionMode: /* i18n */ {
|
||||
id: "appearance.colorVision.title",
|
||||
message: "Color-vision assistance",
|
||||
},
|
||||
colorVisionProtan: /* i18n */ {
|
||||
id: "appearance.colorVision.protan",
|
||||
message: "Red-weak assistance (protan)",
|
||||
},
|
||||
colorVisionStandard: /* i18n */ {
|
||||
id: "appearance.colorVision.standard",
|
||||
message: "Standard colors",
|
||||
},
|
||||
colorVisionStandardDescription: /* i18n */ {
|
||||
id: "appearance.colorVision.standard.description",
|
||||
message: "Uses the selected accent and the default semantic colors",
|
||||
},
|
||||
colorVisionTritan: /* i18n */ {
|
||||
id: "appearance.colorVision.tritan",
|
||||
message: "Blue-yellow assistance (tritan)",
|
||||
},
|
||||
darkMode: /* i18n */ {
|
||||
id: "appearance.mode.dark",
|
||||
message: "Dark",
|
||||
},
|
||||
darkModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.dark.description",
|
||||
message: "The interface will always use the dark theme",
|
||||
},
|
||||
followSystem: /* i18n */ {
|
||||
id: "appearance.mode.system",
|
||||
message: "System",
|
||||
},
|
||||
followSystemDescription: /* i18n */ {
|
||||
id: "appearance.mode.system.description",
|
||||
message: "The interface theme will follow the system appearance",
|
||||
},
|
||||
lightMode: /* i18n */ {
|
||||
id: "appearance.mode.light",
|
||||
message: "Light",
|
||||
},
|
||||
lightModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.light.description",
|
||||
message: "The interface will always use the light theme",
|
||||
},
|
||||
motionFull: /* i18n */ {
|
||||
id: "appearance.motion.full",
|
||||
message: "Full motion",
|
||||
},
|
||||
motionFullDescription: /* i18n */ {
|
||||
id: "appearance.motion.full.description",
|
||||
message: "Allows all interface motion and transitions",
|
||||
},
|
||||
motionMode: /* i18n */ {
|
||||
id: "appearance.motion.title",
|
||||
message: "Motion",
|
||||
},
|
||||
motionReduce: /* i18n */ {
|
||||
id: "appearance.motion.reduce",
|
||||
message: "Reduce motion",
|
||||
},
|
||||
motionReduceDescription: /* i18n */ {
|
||||
id: "appearance.motion.reduce.description",
|
||||
message: "Minimizes non-essential animation and smooth scrolling",
|
||||
},
|
||||
motionSystem: /* i18n */ {
|
||||
id: "appearance.motion.system",
|
||||
message: "System",
|
||||
},
|
||||
motionSystemDescription: /* i18n */ {
|
||||
id: "appearance.motion.system.description",
|
||||
message: "Follows the operating system motion preference",
|
||||
},
|
||||
appearanceDescription: /* i18n */ {
|
||||
id: "appearance.description",
|
||||
message: "Theme, color, text, motion, and brightness preferences",
|
||||
},
|
||||
themeColors: /* i18n */ {
|
||||
id: "appearance.themeColors",
|
||||
message: "Theme colors",
|
||||
},
|
||||
themeColorsDescription: /* i18n */ {
|
||||
id: "appearance.themeColors.description",
|
||||
message: "Choose one base color and accent color for every theme mode",
|
||||
},
|
||||
themeMode: /* i18n */ {
|
||||
id: "appearance.mode.title",
|
||||
message: "Theme mode",
|
||||
},
|
||||
textScale: /* i18n */ {
|
||||
id: "appearance.textScale.title",
|
||||
message: "Text size",
|
||||
},
|
||||
textScaleDescription: /* i18n */ {
|
||||
id: "appearance.textScale.description",
|
||||
message: "Scales text, controls, and spacing so content can reflow",
|
||||
},
|
||||
toggleTheme: /* i18n */ {
|
||||
id: "appearance.toggleTheme",
|
||||
message: "Toggle theme",
|
||||
},
|
||||
toggleThemeDescription: /* i18n */ {
|
||||
id: "appearance.toggleTheme.description",
|
||||
message: "Switch between light and dark themes",
|
||||
},
|
||||
switchToDark: /* i18n */ {
|
||||
id: "appearance.toggleTheme.dark",
|
||||
message: "Switch to dark mode",
|
||||
},
|
||||
switchToLight: /* i18n */ {
|
||||
id: "appearance.toggleTheme.light",
|
||||
message: "Switch to light mode",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
|
||||
export const themeColorMessages = {
|
||||
amber: /* i18n */ {
|
||||
id: "appearance.color.amber",
|
||||
message: "Amber",
|
||||
},
|
||||
blue: /* i18n */ {
|
||||
id: "appearance.color.blue",
|
||||
message: "Blue",
|
||||
},
|
||||
cyan: /* i18n */ {
|
||||
id: "appearance.color.cyan",
|
||||
message: "Cyan",
|
||||
},
|
||||
emerald: /* i18n */ {
|
||||
id: "appearance.color.emerald",
|
||||
message: "Emerald",
|
||||
},
|
||||
fuchsia: /* i18n */ {
|
||||
id: "appearance.color.fuchsia",
|
||||
message: "Fuchsia",
|
||||
},
|
||||
green: /* i18n */ {
|
||||
id: "appearance.color.green",
|
||||
message: "Green",
|
||||
},
|
||||
indigo: /* i18n */ {
|
||||
id: "appearance.color.indigo",
|
||||
message: "Indigo",
|
||||
},
|
||||
lime: /* i18n */ {
|
||||
id: "appearance.color.lime",
|
||||
message: "Lime",
|
||||
},
|
||||
mauve: /* i18n */ {
|
||||
id: "appearance.color.mauve",
|
||||
message: "Mauve",
|
||||
},
|
||||
mist: /* i18n */ {
|
||||
id: "appearance.color.mist",
|
||||
message: "Mist",
|
||||
},
|
||||
neutral: /* i18n */ {
|
||||
id: "appearance.color.neutral",
|
||||
message: "Neutral",
|
||||
},
|
||||
olive: /* i18n */ {
|
||||
id: "appearance.color.olive",
|
||||
message: "Olive",
|
||||
},
|
||||
orange: /* i18n */ {
|
||||
id: "appearance.color.orange",
|
||||
message: "Orange",
|
||||
},
|
||||
pink: /* i18n */ {
|
||||
id: "appearance.color.pink",
|
||||
message: "Pink",
|
||||
},
|
||||
purple: /* i18n */ {
|
||||
id: "appearance.color.purple",
|
||||
message: "Purple",
|
||||
},
|
||||
red: /* i18n */ {
|
||||
id: "appearance.color.red",
|
||||
message: "Red",
|
||||
},
|
||||
rose: /* i18n */ {
|
||||
id: "appearance.color.rose",
|
||||
message: "Rose",
|
||||
},
|
||||
sky: /* i18n */ {
|
||||
id: "appearance.color.sky",
|
||||
message: "Sky",
|
||||
},
|
||||
stone: /* i18n */ {
|
||||
id: "appearance.color.stone",
|
||||
message: "Stone",
|
||||
},
|
||||
taupe: /* i18n */ {
|
||||
id: "appearance.color.taupe",
|
||||
message: "Taupe",
|
||||
},
|
||||
teal: /* i18n */ {
|
||||
id: "appearance.color.teal",
|
||||
message: "Teal",
|
||||
},
|
||||
violet: /* i18n */ {
|
||||
id: "appearance.color.violet",
|
||||
message: "Violet",
|
||||
},
|
||||
yellow: /* i18n */ {
|
||||
id: "appearance.color.yellow",
|
||||
message: "Yellow",
|
||||
},
|
||||
zinc: /* i18n */ {
|
||||
id: "appearance.color.zinc",
|
||||
message: "Zinc",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
isAppearancePreferenceUpdate,
|
||||
} from "./preferences"
|
||||
|
||||
describe("appearance preferences", () => {
|
||||
it("creates stable defaults", () => {
|
||||
expect(defaultAppearancePreferences).toEqual({
|
||||
"accent-color": "blue",
|
||||
"base-color": "neutral",
|
||||
brightness: "normal",
|
||||
"color-vision-mode": "standard",
|
||||
"motion-mode": "system",
|
||||
"text-scale": "100",
|
||||
"theme-mode": "system",
|
||||
})
|
||||
})
|
||||
|
||||
it("serializes and validates preference updates", () => {
|
||||
const accentColor = appearancePreferenceDefinitions["accent-color"]
|
||||
const colorVisionMode = appearancePreferenceDefinitions["color-vision-mode"]
|
||||
|
||||
expect(accentColor.parse(accentColor.serialize("violet"))).toBe("violet")
|
||||
expect(colorVisionMode.parse("deutan")).toBe("deutan")
|
||||
expect(colorVisionMode.parse("unknown")).toBe("standard")
|
||||
expect(appearancePreferenceDefinitions.brightness.parse("dim")).toBe("dim")
|
||||
expect(appearancePreferenceDefinitions["motion-mode"].parse("bad")).toBe(
|
||||
"system"
|
||||
)
|
||||
expect(appearancePreferenceDefinitions["text-scale"].parse("200")).toBe(
|
||||
"200"
|
||||
)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({
|
||||
key: "color-vision-mode",
|
||||
value: "tritan",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
createPreferenceUpdateGuard,
|
||||
type PreferenceDefinitions,
|
||||
type PreferenceUpdateFor,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import { isColorVisionMode, type ColorVisionMode } from "./color-vision"
|
||||
import {
|
||||
isBrightness,
|
||||
isMotionMode,
|
||||
isTextScale,
|
||||
type Brightness,
|
||||
type MotionMode,
|
||||
type TextScale,
|
||||
} from "./display"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type ThemeColorConfig,
|
||||
} from "./theme"
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system"
|
||||
|
||||
export interface AppearancePreferences {
|
||||
"accent-color": AccentColor
|
||||
"base-color": BaseColor
|
||||
brightness: Brightness
|
||||
"color-vision-mode": ColorVisionMode
|
||||
"motion-mode": MotionMode
|
||||
"text-scale": TextScale
|
||||
"theme-mode": ThemeMode
|
||||
}
|
||||
|
||||
declare module "@workspace/preferences" {
|
||||
interface PreferencesCustom extends AppearancePreferences {}
|
||||
}
|
||||
|
||||
export type AppearancePreferenceKey = keyof AppearancePreferences
|
||||
export type AppearancePreferenceValue<Key extends AppearancePreferenceKey> =
|
||||
AppearancePreferences[Key]
|
||||
export type AppearancePreferenceUpdate =
|
||||
PreferenceUpdateFor<AppearancePreferences>
|
||||
|
||||
export const defaultThemeColorConfig: ThemeColorConfig = {
|
||||
accentColor: "blue",
|
||||
baseColor: "neutral",
|
||||
}
|
||||
|
||||
export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
|
||||
if (!value || typeof value !== "object") return false
|
||||
|
||||
const config = value as {
|
||||
accentColor?: unknown
|
||||
baseColor?: unknown
|
||||
}
|
||||
|
||||
return (
|
||||
typeof config.baseColor === "string" &&
|
||||
baseColors.some((color) => color === config.baseColor) &&
|
||||
typeof config.accentColor === "string" &&
|
||||
accentColors.some((color) => color === config.accentColor)
|
||||
)
|
||||
}
|
||||
|
||||
function isAccentColor(value: unknown): value is AccentColor {
|
||||
return accentColors.some((color) => color === value)
|
||||
}
|
||||
|
||||
function isBaseColor(value: unknown): value is BaseColor {
|
||||
return baseColors.some((color) => color === value)
|
||||
}
|
||||
|
||||
function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
}
|
||||
|
||||
export const appearancePreferenceDefinitions: PreferenceDefinitions<AppearancePreferences> =
|
||||
{
|
||||
"accent-color": {
|
||||
cookie: "ui-accent-color",
|
||||
defaultValue: defaultThemeColorConfig.accentColor,
|
||||
is: isAccentColor,
|
||||
parse: (value) => (isAccentColor(value) ? value : "blue"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"base-color": {
|
||||
cookie: "ui-base-color",
|
||||
defaultValue: defaultThemeColorConfig.baseColor,
|
||||
is: isBaseColor,
|
||||
parse: (value) => (isBaseColor(value) ? value : "neutral"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
brightness: {
|
||||
cookie: "ui-brightness",
|
||||
defaultValue: "normal",
|
||||
is: isBrightness,
|
||||
parse: (value) => (isBrightness(value) ? value : "normal"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"color-vision-mode": {
|
||||
cookie: "ui-color-vision",
|
||||
defaultValue: "standard",
|
||||
is: isColorVisionMode,
|
||||
parse: (value) => (isColorVisionMode(value) ? value : "standard"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"motion-mode": {
|
||||
cookie: "ui-motion",
|
||||
defaultValue: "system",
|
||||
is: isMotionMode,
|
||||
parse: (value) => (isMotionMode(value) ? value : "system"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"text-scale": {
|
||||
cookie: "ui-text-scale",
|
||||
defaultValue: "100",
|
||||
is: isTextScale,
|
||||
parse: (value) => (isTextScale(value) ? value : "100"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"theme-mode": {
|
||||
cookie: "ui-theme",
|
||||
defaultValue: "system",
|
||||
is: isThemeMode,
|
||||
parse: (value) => (isThemeMode(value) ? value : "system"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultAppearancePreferences = createDefaultPreferences(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
|
||||
export const isAppearancePreferenceUpdate = createPreferenceUpdateGuard(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
@source "../";
|
||||
|
||||
.ui\:theme-changing *,
|
||||
.ui\:theme-changing *::before,
|
||||
.ui\:theme-changing *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="100"] {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="125"] {
|
||||
font-size: 125%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="150"] {
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
html[data-ui-text-scale="200"] {
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] *,
|
||||
html[data-ui-motion="reduce"] *::before,
|
||||
html[data-ui-motion="reduce"] *::after {
|
||||
animation-delay: 0ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-delay: 0ms !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
|
||||
@keyframes ui-reduced-motion-status {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-ui-motion="reduce"] [data-slot="spinner"],
|
||||
html[data-ui-motion="reduce"] [data-slot="skeleton"],
|
||||
html[data-ui-motion="reduce"] [data-slot="toast-icon"] .animate-spin {
|
||||
animation: ui-reduced-motion-status 2s ease-in-out infinite !important;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
buildThemeStyleSheet,
|
||||
getTheme,
|
||||
getThemeCssVariables,
|
||||
themes,
|
||||
} from "./theme"
|
||||
|
||||
describe("appearance themes", () => {
|
||||
it("defines every selectable base and accent color exactly once", () => {
|
||||
const selectableColors = [...baseColors, ...accentColors]
|
||||
|
||||
expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
|
||||
expect(new Set(selectableColors).size).toBe(selectableColors.length)
|
||||
expect(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
|
||||
})
|
||||
|
||||
it("combines base and accent palettes", () => {
|
||||
const variables = getThemeCssVariables("light", {
|
||||
accentColor: "rose",
|
||||
baseColor: "stone",
|
||||
})
|
||||
|
||||
expect(variables.background).toBe(
|
||||
getTheme("stone")?.cssVars.light.background
|
||||
)
|
||||
expect(variables.highlight).toBe(getTheme("stone")?.cssVars.light.highlight)
|
||||
expect(variables.accent).toBe(getTheme("rose")?.cssVars.light.accent)
|
||||
})
|
||||
|
||||
it("builds explicit and system color-scheme rules", () => {
|
||||
const styleSheet = buildThemeStyleSheet(
|
||||
{ accentColor: "blue", baseColor: "neutral" },
|
||||
"deutan",
|
||||
"dim"
|
||||
)
|
||||
|
||||
expect(styleSheet).toContain(":root,.ui\\:light{")
|
||||
expect(styleSheet).toContain(".ui\\:dark{")
|
||||
expect(styleSheet).toContain("@media (prefers-color-scheme:dark)")
|
||||
expect(styleSheet).toContain("--background:")
|
||||
expect(styleSheet).toContain("--accent:")
|
||||
expect(styleSheet).toContain("--highlight:")
|
||||
expect(styleSheet).toContain("--destructive:#a64b00")
|
||||
expect(styleSheet).toContain("--destructive:#ff9d66")
|
||||
expect(styleSheet).toContain("--background:color-mix(in oklch,")
|
||||
expect(styleSheet).not.toContain("filter:")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
themes,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./internal/themes"
|
||||
import {
|
||||
getColorVisionCssVariables,
|
||||
type ColorVisionMode,
|
||||
} from "./color-vision"
|
||||
import { applyBrightnessToTheme, type Brightness } from "./display"
|
||||
|
||||
export {
|
||||
accentColors,
|
||||
baseColors,
|
||||
themes,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
type Theme,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "./internal/themes"
|
||||
|
||||
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
|
||||
|
||||
export const getTheme = (name: BaseColor | AccentColor) =>
|
||||
themes.find((theme) => theme.name === name)
|
||||
|
||||
export function getThemeCssVariables(
|
||||
scheme: ThemeScheme,
|
||||
config: ThemeColorConfig
|
||||
) {
|
||||
const base = getTheme(config.baseColor) ?? getTheme("neutral")!
|
||||
const accent = getTheme(config.accentColor) ?? getTheme("blue")!
|
||||
|
||||
return {
|
||||
...base.cssVars[scheme],
|
||||
...accent.cssVars[scheme],
|
||||
}
|
||||
}
|
||||
|
||||
function serializeThemeVariables(theme: Record<string, string>): string {
|
||||
return Object.entries(theme)
|
||||
.map(([key, value]) => `--${key}:${value}`)
|
||||
.join(";")
|
||||
}
|
||||
|
||||
export function buildThemeStyleSheet(
|
||||
config: ThemeColorConfig,
|
||||
colorVisionMode: ColorVisionMode = "standard",
|
||||
brightness: Brightness = "normal"
|
||||
) {
|
||||
const light = serializeThemeVariables(
|
||||
applyBrightnessToTheme(
|
||||
{
|
||||
...getThemeCssVariables("light", config),
|
||||
...getColorVisionCssVariables(colorVisionMode, "light"),
|
||||
},
|
||||
brightness,
|
||||
"light"
|
||||
)
|
||||
)
|
||||
const dark = serializeThemeVariables(
|
||||
applyBrightnessToTheme(
|
||||
{
|
||||
...getThemeCssVariables("dark", config),
|
||||
...getColorVisionCssVariables(colorVisionMode, "dark"),
|
||||
},
|
||||
brightness,
|
||||
"dark"
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
`:root,.ui\\:light{${light}}`,
|
||||
`.ui\\:dark{${dark}}`,
|
||||
`@media (prefers-color-scheme:dark){:root:not(.ui\\:light):not(.ui\\:dark){${dark}}}`,
|
||||
].join("")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/appearance": ["./src/index.ts"],
|
||||
"@workspace/appearance/*": ["./src/*"],
|
||||
"@workspace/i18n": ["../i18n/src/index.ts"],
|
||||
"@workspace/preferences": ["../preferences/src/index.tsx"],
|
||||
"@workspace/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
title: Media library
|
||||
description: Supply a storage adapter and add browsing, upload, folders, and asset selection.
|
||||
order: 21
|
||||
toc:
|
||||
- id: implement-the-adapter
|
||||
title: Implement the adapter
|
||||
- id: provide-media
|
||||
title: Provide media
|
||||
- id: selection-flows
|
||||
title: Selection flows
|
||||
---
|
||||
|
||||
## Implement the adapter {#implement-the-adapter}
|
||||
|
||||
`MediaAdapter` is the media feature's complete persistence boundary. Implement its query, upload, folder, update, and delete operations with the product's storage service.
|
||||
|
||||
## Provide media {#provide-media}
|
||||
|
||||
Mount the adapter once around the media surfaces:
|
||||
|
||||
```tsx
|
||||
import { MediaLibrary, MediaProvider } from "@workspace/blocks/media"
|
||||
|
||||
;<MediaProvider adapter={mediaAdapter} notify={showNotice}>
|
||||
<MediaLibrary />
|
||||
</MediaProvider>
|
||||
```
|
||||
|
||||
The optional `notify` callback translates operational results into the application's toast or notification system.
|
||||
|
||||
## Selection flows {#selection-flows}
|
||||
|
||||
Use `MediaPickerDialog` for single or multiple asset selection. `readMediaDimensions`, `formatMediaFileSize`, and `defaultMediaReference` cover the common presentation work around uploads and previews.
|
||||
@@ -33,7 +33,7 @@ Import the block stylesheet once from the application entry:
|
||||
import "@workspace/blocks/globals.css"
|
||||
```
|
||||
|
||||
Keep feature imports on their explicit subpaths. For example, importing `@workspace/blocks/media` does not pull chat into the same module graph.
|
||||
Keep feature imports on their explicit subpaths. For example, importing `@workspace/blocks/notifications` does not pull chat into the same module graph. Media workflows are provided separately by `@workspace/media`.
|
||||
|
||||
## Application providers {#application-providers}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ toc:
|
||||
|
||||
## What you will build {#what-you-will-build}
|
||||
|
||||
This tutorial builds an application shell step by step. You will begin with the shared layout, connect navigation, then add notifications, chat, media, and appearance preferences.
|
||||
This tutorial builds an application shell step by step. You will begin with the shared layout, connect navigation, then add notifications, chat, and appearance preferences.
|
||||
|
||||
By the end, the application owns its data and business rules while `@workspace/blocks` owns the reusable presentation and interaction patterns.
|
||||
|
||||
@@ -29,5 +29,5 @@ Blocks sit above `@workspace/ui`. They combine low-level components into feature
|
||||
2. Create the application shell.
|
||||
3. Define navigation and route state.
|
||||
4. Connect notifications.
|
||||
5. Add chat and media workflows.
|
||||
5. Add chat workflows.
|
||||
6. Finish with persistent appearance controls.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
title: 媒体库
|
||||
description: 提供存储 Adapter,并加入浏览、上传、文件夹和资源选择能力。
|
||||
order: 21
|
||||
toc:
|
||||
- id: implement-the-adapter
|
||||
title: 实现 Adapter
|
||||
- id: provide-media
|
||||
title: 提供媒体能力
|
||||
- id: selection-flows
|
||||
title: 选择流程
|
||||
---
|
||||
|
||||
## 实现 Adapter {#implement-the-adapter}
|
||||
|
||||
`MediaAdapter` 是媒体功能完整的持久化边界。通过产品存储服务实现查询、上传、文件夹、更新和删除操作。
|
||||
|
||||
## 提供媒体能力 {#provide-media}
|
||||
|
||||
在媒体界面外挂载一次 Adapter:
|
||||
|
||||
```tsx
|
||||
import { MediaLibrary, MediaProvider } from "@workspace/blocks/media"
|
||||
|
||||
;<MediaProvider adapter={mediaAdapter} notify={showNotice}>
|
||||
<MediaLibrary />
|
||||
</MediaProvider>
|
||||
```
|
||||
|
||||
可选的 `notify` 回调把操作结果转换为应用的 Toast 或通知。
|
||||
|
||||
## 选择流程 {#selection-flows}
|
||||
|
||||
单选或多选资源使用 `MediaPickerDialog`。`readMediaDimensions`、`formatMediaFileSize` 和 `defaultMediaReference` 覆盖上传与预览相关的常用展示工作。
|
||||
@@ -33,7 +33,7 @@ Blocks 使用 `@workspace/ui`、React Query 和工作区国际化运行时。工
|
||||
import "@workspace/blocks/globals.css"
|
||||
```
|
||||
|
||||
功能模块应始终从显式子路径导入。例如,导入 `@workspace/blocks/media` 不会把聊天功能加入同一个模块图。
|
||||
功能模块应始终从显式子路径导入。例如,导入 `@workspace/blocks/notifications` 不会把聊天功能加入同一个模块图。媒体工作流由独立的 `@workspace/media` 提供。
|
||||
|
||||
## 应用 Providers {#application-providers}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ toc:
|
||||
|
||||
## 将要构建的内容 {#what-you-will-build}
|
||||
|
||||
本教程会逐步构建一个应用外壳。你将从共享布局开始,接入导航,再添加通知、聊天、媒体和外观偏好。
|
||||
本教程会逐步构建一个应用外壳。你将从共享布局开始,接入导航,再添加通知、聊天和外观偏好。
|
||||
|
||||
完成后,应用负责数据和业务规则,`@workspace/blocks` 负责可复用的展示与交互模式。
|
||||
|
||||
@@ -29,5 +29,5 @@ Blocks 位于 `@workspace/ui` 之上。它们将底层组件组合成完整功
|
||||
2. 创建应用外壳。
|
||||
3. 定义导航和路由状态。
|
||||
4. 接入通知。
|
||||
5. 添加聊天和媒体工作流。
|
||||
5. 添加聊天工作流。
|
||||
6. 使用持久化外观控件完成应用。
|
||||
|
||||
@@ -45,9 +45,6 @@
|
||||
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
|
||||
"./layout/locales/*": "./src/blocks/layout/locales/*.ts",
|
||||
"./layouts/vega": "./src/blocks/layouts/vega/index.tsx",
|
||||
"./media": "./src/blocks/media/index.ts",
|
||||
"./media/locales": "./src/blocks/media/locales/catalogs.ts",
|
||||
"./media/locales/*": "./src/blocks/media/locales/*.ts",
|
||||
"./navigation": "./src/blocks/navigation/index.ts",
|
||||
"./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
|
||||
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user