From bd905c392fdc35ad7eac3099fe913006a335f94f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 21:30:43 +0000 Subject: [PATCH 01/10] chore: use the same Changesets setup as sanity-io/ui Match sanity-io/ui's Changesets config, versions, scripts and release flow: - `.changeset/config.json` adds `"format": "oxfmt"` and `"bumpVersionsWithWorkspaceProtocolOnly": true`; only the changelog `repo` differs from ui's - `@changesets/cli` ^3.0.3 and `@changesets/changelog-github` ^1.0.1 as root devDependencies, plus a `changeset` script - `release.yml` drops the npm@11 pin: @changesets/cli 3.0.3 unwraps the `pnpm info --json` output of npm 12 (changesets/changesets#2274) - the Renovate changesets workflow takes ui's name and also runs for `squiggler-legacy[bot]` - the `@sanity/plugin-kit/oxfmt` preset drops the Prettier-era `singleQuote: false` override for `.changeset/*.md`, now that Changesets formats what it writes with oxfmt itself --- .changeset/config.json | 2 + .changeset/plugin-kit-oxfmt-changesets.md | 5 ++ .../changesets-from-conventional-commits.yml | 6 +- .github/workflows/release.yml | 3 - AGENTS.md | 2 + package.json | 5 +- packages/@sanity/plugin-kit/src/oxfmt.ts | 6 -- pnpm-lock.yaml | 78 ++++++++++--------- 8 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 .changeset/plugin-kit-oxfmt-changesets.md diff --git a/.changeset/config.json b/.changeset/config.json index b309736542..bb1c0b6ec6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,7 +3,9 @@ "changelog": ["@changesets/changelog-github", {"repo": "sanity-io/plugins"}], "access": "public", "baseBranch": "main", + "format": "oxfmt", "privatePackages": false, + "bumpVersionsWithWorkspaceProtocolOnly": true, "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "updateInternalDependents": "always", "onlyUpdatePeerDependentsWhenOutOfRange": true diff --git a/.changeset/plugin-kit-oxfmt-changesets.md b/.changeset/plugin-kit-oxfmt-changesets.md new file mode 100644 index 0000000000..8c11013f97 --- /dev/null +++ b/.changeset/plugin-kit-oxfmt-changesets.md @@ -0,0 +1,5 @@ +--- +'@sanity/plugin-kit': patch +--- + +The `@sanity/plugin-kit/oxfmt` preset no longer switches `.changeset/*.md` files to double quotes. The override only kept `oxfmt` from reformatting the changesets that Changesets wrote with Prettier; set `"format": "oxfmt"` in `.changeset/config.json` to have Changesets format them with your oxfmt config instead. diff --git a/.github/workflows/changesets-from-conventional-commits.yml b/.github/workflows/changesets-from-conventional-commits.yml index d5fdd1dfdf..0718ccd3ce 100644 --- a/.github/workflows/changesets-from-conventional-commits.yml +++ b/.github/workflows/changesets-from-conventional-commits.yml @@ -1,7 +1,7 @@ -name: Add changeset to Renovate updates +name: Add changeset from conventional commits on: - pull_request_target: # zizmor: ignore[dangerous-triggers] gated to renovate[bot]/squiggler-app[bot]; no PR code is checked out or executed in the reusable workflow + pull_request_target: # zizmor: ignore[dangerous-triggers] gated to renovate[bot]/squiggler-legacy[bot]/squiggler-app[bot]; no PR code is checked out or executed in the reusable workflow types: [opened, synchronize] concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -12,7 +12,7 @@ permissions: jobs: call: uses: sanity-io/.github/.github/workflows/changesets-from-conventional-commits.yml@main - if: github.event.pull_request.user.login == 'renovate[bot]' || github.event.pull_request.user.login == 'squiggler-app[bot]' + if: github.event.pull_request.user.login == 'renovate[bot]' || github.event.pull_request.user.login == 'squiggler-legacy[bot]' || github.event.pull_request.user.login == 'squiggler-app[bot]' secrets: ECOSPARK_APP_ID: ${{ secrets.ECOSPARK_APP_ID }} ECOSPARK_APP_PRIVATE_KEY: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0635f2893d..f5d9bff870 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,9 +46,6 @@ jobs: cache: pnpm check-latest: true node-version: lts/* - - name: Update npm to use trusted publishing (OIDC) - # npm 12 wraps `pnpm info --json`; @changesets/cli 3.0.1 does not unwrap it (changesets/changesets#2274) - run: npm install -g npm@11 - run: pnpm install - name: Create Release Pull Request or Publish to npm uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 diff --git a/AGENTS.md b/AGENTS.md index 936fcceff8..671367ff72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,8 @@ pnpm test Every PR that changes published packages **must** include changesets. **Important:** Create a separate changeset file for each plugin you modify. +The Changesets setup (`.changeset/config.json`, the `@changesets/*` versions, the `changeset`/`release` scripts and `.github/workflows/release.yml`) matches sanity-io/ui's; only the changelog `repo` differs. `"format": "oxfmt"` makes Changesets format the changesets and changelogs it writes with the repo's oxfmt config, and `"bumpVersionsWithWorkspaceProtocolOnly": true` keeps it from rewriting internal ranges that don't use `workspace:` (like the intentionally wide `sanity-plugin-internationalized-array` peer of `@sanity/sfcc`). + #### Why Separate Changesets? Each plugin has its own changelog that consumers read. A combined changeset would pollute individual plugin changelogs with irrelevant information. For example, `@sanity/code-input`'s changelog should not mention workflow-specific changes. diff --git a/package.json b/package.json index e534ea3280..3df0e5db0f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "turbo build", + "changeset": "changeset", "check:deps": "knip --dependencies", "dev": "pnpm turbo dev --filter=test-studio", "devtools:test-studio": "pnpm devtools:test-studio:build && ENABLE_VITE_DEVTOOLS=true pnpm dev", @@ -34,8 +35,6 @@ "typegen": "pnpm --filter=test-studio run typegen && pnpm format" }, "dependencies": { - "@changesets/changelog-github": "^1.0.0", - "@changesets/cli": "^3.0.1", "@sanity/plugin-kit": "workspace:*", "husky": "^9.1.7", "lint-staged": "^16.4.0", @@ -49,6 +48,8 @@ "vitest-package-exports": "^1.2.0" }, "devDependencies": { + "@changesets/changelog-github": "^1.0.1", + "@changesets/cli": "^3.0.3", "@vitest/coverage-v8": "catalog:", "knip": "^6.33.0", "vercel": "^57.0.0" diff --git a/packages/@sanity/plugin-kit/src/oxfmt.ts b/packages/@sanity/plugin-kit/src/oxfmt.ts index 8aa231b060..d0c876c437 100644 --- a/packages/@sanity/plugin-kit/src/oxfmt.ts +++ b/packages/@sanity/plugin-kit/src/oxfmt.ts @@ -34,12 +34,6 @@ const config: OxfmtConfig = { sortImports: true, sortPackageJson: {sortScripts: true}, ignorePatterns: ['dist/**', 'pnpm-lock.yaml'], - overrides: [ - { - files: ['.changeset/*.md'], - options: {singleQuote: false}, - }, - ], } export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e779e0b27c..657217fc66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,12 +193,6 @@ importers: .: dependencies: - '@changesets/changelog-github': - specifier: ^1.0.0 - version: 1.0.0 - '@changesets/cli': - specifier: ^3.0.1 - version: 3.0.1 '@sanity/plugin-kit': specifier: workspace:* version: link:packages/@sanity/plugin-kit @@ -233,6 +227,12 @@ importers: specifier: ^1.2.0 version: 1.2.0 devDependencies: + '@changesets/changelog-github': + specifier: ^1.0.1 + version: 1.0.1 + '@changesets/cli': + specifier: ^3.0.3 + version: 3.0.3 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.11(vitest@4.1.11) @@ -4189,8 +4189,8 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - '@changesets/apply-release-plan@8.0.0': - resolution: {integrity: sha512-kUd2pbf1w5/AYmBMb0Tt+rkIPCjFJdT0SZMrkOjJT/WV/QbtmvkyB5jkV0oaNPheavprZk+SfUiozUti7TIL2w==} + '@changesets/apply-release-plan@8.1.1': + resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==} engines: {node: ^22.11 || ^24 || >=26} '@changesets/assemble-release-plan@7.0.0': @@ -4201,17 +4201,17 @@ packages: resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} engines: {node: ^22.11 || ^24 || >=26} - '@changesets/changelog-github@1.0.0': - resolution: {integrity: sha512-PNCSH5CGJrqOKxRjrMp2NLhceQv2QBs6HkjrLvYQqsEE2/ItN5R8GD/mskZZluoNWYvjPYFsY9T6swXfp5E0UA==} + '@changesets/changelog-github@1.0.1': + resolution: {integrity: sha512-QUcrV7878yHlWPXXPA6gqSxNKwtVHCd1K22L7ZAoJw2yGy8K4QvjwOStD75+1Q9KD9ZSUgGS94HuYdd7HOVtpA==} engines: {node: ^22.11 || ^24 || >=26} - '@changesets/cli@3.0.1': - resolution: {integrity: sha512-3IVpRSgiKDj2aRbBHKtWTXSRH2/iR3bWxau9iQUoEsZjDhRIj9nhl90k7FWMxZAJvLgJBbgMq8+qS0xQsenYsQ==} + '@changesets/cli@3.0.3': + resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==} engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} hasBin: true - '@changesets/config@4.0.0': - resolution: {integrity: sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow==} + '@changesets/config@4.0.1': + resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==} engines: {node: ^22.11 || ^24 || >=26} '@changesets/errors@1.0.0': @@ -4226,12 +4226,12 @@ packages: resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} engines: {node: ^22.11 || ^24 || >=26} - '@changesets/get-github-info@1.0.0': - resolution: {integrity: sha512-nf5zPSqEKW0eXiJ4ltbjyIQ/srUHfxbghkLCjKdOLI3LehKXhiqxXcpja39eGPaj6qqGx2lFCrLJ7VWYUf9H2w==} + '@changesets/get-github-info@1.0.1': + resolution: {integrity: sha512-ifLQCky/ZtDgZ4xCiUMjnLZSJ8xk52iIzMJbBBPlZWjr2CiTNTaTDddIVWKicrUAdWuLWL7PUw2fNa1yPwLpIg==} engines: {node: ^22.11 || ^24 || >=26} - '@changesets/git@4.0.0': - resolution: {integrity: sha512-uIEswpPUgzBBqrC0qg13byNaPorzhf05LE2T+gRizEKCXvMsJC6NPJp5iNDSV/gYj4Viqh09UiOF5E7XWeH5lQ==} + '@changesets/git@4.0.1': + resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} engines: {node: ^22.11 || ^24 || >=26} '@changesets/parse@1.0.0': @@ -4242,8 +4242,8 @@ packages: resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} engines: {node: ^22.11 || ^24 || >=26} - '@changesets/read@1.0.0': - resolution: {integrity: sha512-8TdE2PwG6yArPt5Ozej83Z6iHz1G8BDBKvIEKZ455MccR4K3GNbLR2XqjBxa+5yLFnEd3xAOPXYboBg1pt8stQ==} + '@changesets/read@1.0.1': + resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} engines: {node: ^22.11 || ^24 || >=26} '@changesets/should-skip-package@1.0.0': @@ -13976,6 +13976,10 @@ packages: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -17260,11 +17264,11 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@changesets/apply-release-plan@8.0.0': + '@changesets/apply-release-plan@8.1.1': dependencies: - '@changesets/config': 4.0.0 + '@changesets/config': 4.0.1 '@changesets/format': 0.1.2 - '@changesets/git': 4.0.0 + '@changesets/git': 4.0.1 '@changesets/should-skip-package': 1.0.0 '@changesets/types': 7.0.0 import-meta-resolve: 4.2.0 @@ -17283,22 +17287,22 @@ snapshots: dependencies: '@changesets/types': 7.0.0 - '@changesets/changelog-github@1.0.0': + '@changesets/changelog-github@1.0.1': dependencies: - '@changesets/get-github-info': 1.0.0 + '@changesets/get-github-info': 1.0.1 '@changesets/types': 7.0.0 - '@changesets/cli@3.0.1': + '@changesets/cli@3.0.3': dependencies: - '@changesets/apply-release-plan': 8.0.0 + '@changesets/apply-release-plan': 8.1.1 '@changesets/assemble-release-plan': 7.0.0 '@changesets/changelog-git': 1.0.0 - '@changesets/config': 4.0.0 + '@changesets/config': 4.0.1 '@changesets/errors': 1.0.0 '@changesets/get-dependents-graph': 3.0.0 - '@changesets/git': 4.0.0 + '@changesets/git': 4.0.1 '@changesets/pre': 3.0.0 - '@changesets/read': 1.0.0 + '@changesets/read': 1.0.1 '@changesets/should-skip-package': 1.0.0 '@changesets/types': 7.0.0 '@changesets/write': 1.0.1 @@ -17310,9 +17314,9 @@ snapshots: launch-editor: 2.14.1 package-manager-detector: 1.8.0 semver: 7.8.5 - tinyexec: 1.3.0 + tinyexec: 1.3.1 - '@changesets/config@4.0.0': + '@changesets/config@4.0.1': dependencies: '@changesets/get-dependents-graph': 3.0.0 '@changesets/should-skip-package': 1.0.0 @@ -17332,11 +17336,11 @@ snapshots: '@changesets/types': 7.0.0 semver: 7.8.5 - '@changesets/get-github-info@1.0.0': + '@changesets/get-github-info@1.0.1': dependencies: dataloader: 2.2.3 - '@changesets/git@4.0.0': + '@changesets/git@4.0.1': dependencies: '@changesets/errors': 1.0.0 '@changesets/types': 7.0.0 @@ -17355,9 +17359,9 @@ snapshots: '@changesets/types': 7.0.0 '@manypkg/get-packages': 3.1.0 - '@changesets/read@1.0.0': + '@changesets/read@1.0.1': dependencies: - '@changesets/git': 4.0.0 + '@changesets/git': 4.0.1 '@changesets/parse': 1.0.0 '@changesets/types': 7.0.0 @@ -29430,6 +29434,8 @@ snapshots: tinyexec@1.3.0: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) From a5f929cc7b9b12249f2f9d63637250e35b75028b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 21:31:23 +0000 Subject: [PATCH 02/10] fix(deps): align @sanity/ui, @sanity/icons, motion and @sanity/vanilla-extract-vite-plugin with sanity-io/ui Bump the catalog to the versions sanity-io/ui develops @sanity/themer against, which are also the ranges @sanity/themer@0.8.0 was published with: @sanity/ui ^4.2.7, @sanity/icons ^5.2.2, motion ^13.4.4 and @sanity/vanilla-extract-vite-plugin ^0.2.20. The lockfile now resolves a single @sanity/ui (4.2.7, the version sanity@next uses too), @sanity/icons and motion instead of two of each. --- pnpm-lock.yaml | 385 +++++++++++++++++++++++++++----------------- pnpm-workspace.yaml | 8 +- 2 files changed, 241 insertions(+), 152 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 657217fc66..e67b2a5a3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ catalogs: specifier: ^3.0.8 version: 3.0.8 '@sanity/icons': - specifier: ^5.2.1 - version: 5.2.1 + specifier: ^5.2.2 + version: 5.2.2 '@sanity/image-url': specifier: ^2.1.1 version: 2.1.1 @@ -56,14 +56,14 @@ catalogs: specifier: ^0.27.1 version: 0.27.1 '@sanity/ui': - specifier: ^4.0.7 - version: 4.0.7 + specifier: ^4.2.7 + version: 4.2.7 '@sanity/uuid': specifier: ^3.0.3 version: 3.0.3 '@sanity/vanilla-extract-vite-plugin': - specifier: ^0.2.17 - version: 0.2.17 + specifier: ^0.2.20 + version: 0.2.20 '@testing-library/jest-dom': specifier: ^7.0.1 version: 7.0.1 @@ -113,8 +113,8 @@ catalogs: specifier: ^4.18.1 version: 4.18.1 motion: - specifier: ^13.1.0 - version: 13.1.0 + specifier: ^13.4.4 + version: 13.4.4 nanoid: specifier: ^6.0.1 version: 6.0.1 @@ -320,7 +320,7 @@ importers: version: link:../../plugins/@sanity/hierarchical-document-list '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/language-filter': specifier: workspace:* version: link:../../plugins/@sanity/language-filter @@ -353,10 +353,10 @@ importers: version: 0.8.0(@types/react@19.3.0)(react-dom@19.3.0)(react@19.3.0)(sanity@6.17.0-next.108)(styled-components@6.5.3) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/vanilla-extract-vite-plugin': specifier: 'catalog:' - version: 0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1) + version: 0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1) '@sanity/vercel-protection-bypass': specifier: workspace:* version: link:../../plugins/@sanity/vercel-protection-bypass @@ -595,13 +595,13 @@ importers: version: 3.0.8 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/mutator': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) date-fns: specifier: 'catalog:' version: 4.4.0 @@ -671,10 +671,10 @@ importers: version: 6.0.21(@portabletext/editor@7.12.3)(@types/react@19.3.0)(react@19.3.0) '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -759,13 +759,13 @@ importers: version: 1.2.3 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/lezer-groq': specifier: ^1.0.4 version: 1.0.4 '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@uiw/codemirror-themes': specifier: ^4.25.11 version: 4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13) @@ -805,10 +805,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@vanilla-extract/dynamic': specifier: 'catalog:' version: 2.1.5 @@ -830,7 +830,7 @@ importers: version: 0.27.1(@babel/core@7.29.7)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.147.0)(tsdown@0.22.14)(typescript@7.0.2)(vite@8.3.1) '@sanity/vanilla-extract-vite-plugin': specifier: 'catalog:' - version: 0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1) + version: 0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1) '@types/lodash-es': specifier: 'catalog:' version: 4.17.12 @@ -863,7 +863,7 @@ importers: version: 2.3.0 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/mutator': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -872,7 +872,7 @@ importers: version: link:../studio-secrets '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) dset: specifier: ^3.1.4 version: 3.1.4 @@ -915,13 +915,13 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/image-url': specifier: 'catalog:' version: 2.1.1 '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) rxjs: specifier: 'catalog:' version: 7.8.2 @@ -964,7 +964,7 @@ importers: version: 8.6.1(vitest@4.1.11) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -1004,7 +1004,7 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/preview-url-secret': specifier: 'catalog:' version: 4.1.5(@sanity/client@8.6.1) @@ -1035,13 +1035,13 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/mutator': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -1105,10 +1105,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) react-rx: specifier: ^4.2.5 version: 4.2.5(react@19.3.0)(rxjs@7.8.2) @@ -1160,7 +1160,7 @@ importers: version: link:../sanity-plugin-async-list '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) h3: specifier: ^1.15.11 version: 1.15.11 @@ -1212,10 +1212,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@vis.gl/react-google-maps': specifier: 'catalog:' version: 1.9.0(react-dom@19.3.0)(react@19.3.0) @@ -1231,7 +1231,7 @@ importers: version: 0.27.1(@babel/core@7.29.7)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.147.0)(tsdown@0.22.14)(typescript@7.0.2)(vite@8.3.1) '@sanity/vanilla-extract-vite-plugin': specifier: 'catalog:' - version: 0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1) + version: 0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1) '@types/google.maps': specifier: ^3.65.5 version: 3.65.5 @@ -1270,13 +1270,13 @@ importers: version: 3.0.8 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/mutator': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -1322,10 +1322,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) react-rx: specifier: 'catalog:' version: 4.2.5(react@19.3.0)(rxjs@7.8.2) @@ -1386,10 +1386,10 @@ importers: version: 8.6.1(vitest@4.1.11) '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) lexorank: specifier: 'catalog:' version: 1.0.5 @@ -1432,13 +1432,13 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/studio-secrets': specifier: workspace:^ version: link:../studio-secrets '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/uuid': specifier: 'catalog:' version: 3.0.3 @@ -1545,10 +1545,10 @@ importers: version: 1.5.0 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) date-fns: specifier: 'catalog:' version: 4.4.0 @@ -1591,13 +1591,13 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/studio-secrets': specifier: workspace:^ version: link:../studio-secrets '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) lodash-es: specifier: 'catalog:' version: 4.18.1 @@ -1640,10 +1640,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -1686,7 +1686,7 @@ importers: dependencies: '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) react-rx: specifier: 'catalog:' version: 4.2.5(react@19.3.0)(rxjs@7.8.2) @@ -1741,10 +1741,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/uuid': specifier: 'catalog:' version: 3.0.3 @@ -1787,13 +1787,13 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/preview-url-secret': specifier: 'catalog:' version: 4.1.5(@sanity/client@8.6.1) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -1888,7 +1888,7 @@ importers: dependencies: '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -1922,10 +1922,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) lodash-es: specifier: 'catalog:' version: 4.18.1 @@ -1971,7 +1971,7 @@ importers: version: 6.0.1(@types/react@19.3.0)(immer@11.1.17)(react-dom@19.3.0)(react@19.3.0)(supports-color@10.2.2)(use-sync-external-store@1.7.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -1987,7 +1987,7 @@ importers: version: 0.27.1(@babel/core@7.29.7)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.147.0)(tsdown@0.22.14)(typescript@7.0.2)(vite@8.3.1) '@sanity/vanilla-extract-vite-plugin': specifier: 'catalog:' - version: 0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1) + version: 0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1) '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -2023,13 +2023,13 @@ importers: version: 1.22.0 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/studio-secrets': specifier: workspace:^ version: link:../@sanity/studio-secrets '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) nanoid: specifier: 'catalog:' version: 6.0.1 @@ -2069,7 +2069,7 @@ importers: dependencies: '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) lodash-es: specifier: 'catalog:' version: 4.18.1 @@ -2115,7 +2115,7 @@ importers: dependencies: '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -2161,10 +2161,10 @@ importers: version: 3.0.8 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/uuid': specifier: 'catalog:' version: 3.0.3 @@ -2234,10 +2234,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -2292,7 +2292,7 @@ importers: dependencies: '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -2332,7 +2332,7 @@ importers: version: 3.0.8 '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) bezier-easing: specifier: ^2.1.0 version: 2.1.0 @@ -2387,7 +2387,7 @@ importers: version: 2.1.1 '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -2396,7 +2396,7 @@ importers: version: 4.18.1 motion: specifier: 'catalog:' - version: 13.1.0(react-dom@19.3.0)(react@19.3.0) + version: 13.4.4(react-dom@19.3.0)(react@19.3.0) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -2442,19 +2442,19 @@ importers: version: 8.6.1(vitest@4.1.11) '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/preview-url-secret': specifier: 'catalog:' version: 4.1.5(@sanity/client@8.6.1) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) dequal: specifier: 'catalog:' version: 2.0.3 motion: specifier: 'catalog:' - version: 13.1.0(react-dom@19.3.0)(react@19.3.0) + version: 13.4.4(react-dom@19.3.0)(react@19.3.0) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -2500,10 +2500,10 @@ importers: version: link:../@sanity/assist '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -2601,7 +2601,7 @@ importers: version: 8.6.1(vitest@4.1.11) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) react-simplemde-editor: specifier: ^5.2.0 version: 5.2.0(easymde@2.21.0)(react-dom@19.3.0)(react@19.3.0) @@ -2653,10 +2653,10 @@ importers: version: 3.0.8 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/uuid': specifier: 'catalog:' version: 3.0.3 @@ -2777,10 +2777,10 @@ importers: version: 3.5.0 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/uuid': specifier: 'catalog:' version: 3.0.3 @@ -2865,10 +2865,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) axios: specifier: ^1.19.0 version: 1.20.0(debug@4.4.3)(supports-color@10.2.2) @@ -3006,13 +3006,13 @@ importers: version: 2.3.0 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/image-url': specifier: 'catalog:' version: 2.1.1 '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) dequal: specifier: 'catalog:' version: 2.0.3 @@ -3058,10 +3058,10 @@ importers: version: 18.0.1(@types/react@19.3.0)(react-dom@19.3.0)(react@19.3.0) '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@tanstack/react-virtual': specifier: ^3.14.10 version: 3.14.13(react-dom@19.3.0)(react@19.3.0) @@ -3070,7 +3070,7 @@ importers: version: 1.0.5 motion: specifier: 'catalog:' - version: 13.1.0(react-dom@19.3.0)(react@19.3.0) + version: 13.4.4(react-dom@19.3.0)(react@19.3.0) sanity: specifier: next version: 6.17.0-next.108(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(@types/react-dom@19.3.0)(@types/react@19.3.0)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(immer@11.1.17)(jiti@2.7.0)(oxc-transform-react@0.147.0)(oxfmt@0.63.0)(react-dom@19.3.0)(react@19.3.0)(rolldown@1.2.11)(styled-components@6.5.3)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(vitest@4.1.11) @@ -3086,7 +3086,7 @@ importers: version: 0.27.1(@babel/core@7.29.7)(@rolldown/plugin-babel@0.2.3)(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.147.0)(tsdown@0.22.14)(typescript@7.0.2)(vite@8.3.1) '@sanity/vanilla-extract-vite-plugin': specifier: 'catalog:' - version: 0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1) + version: 0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1) '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -3113,10 +3113,10 @@ importers: dependencies: '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) react-is: specifier: 'catalog:' version: 19.3.0 @@ -3162,10 +3162,10 @@ importers: version: 5.0.3 '@sanity/icons': specifier: 'catalog:' - version: 5.2.1(react@19.3.0) + version: 5.2.2(react@19.3.0) '@sanity/ui': specifier: 'catalog:' - version: 4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) + version: 4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3) '@sanity/util': specifier: next version: 6.17.0-next.108(@types/react@19.3.0)(vitest@4.1.11) @@ -7541,12 +7541,6 @@ packages: resolution: {integrity: sha512-+fC2CBbbL5kk4rSO5oDOawJQU+nC2qlyvR8WfJtcrlB7sYJq0vBoj16jyE+cKSw7zt5ZpxhYWkAEgSM6BkQXAQ==} engines: {node: '>=20'} - '@sanity/icons@5.2.1': - resolution: {integrity: sha512-P5gY1HRungh4RgCcypdIpu/SKoTwRBTttpbHntZjdW1MO3MK6xEOs+pbAzSARyhh22OcN9PkBBEYk2FL34BvfQ==} - engines: {node: '>=20.19 <22 || >=22.12'} - peerDependencies: - react: ^18 || ^19 - '@sanity/icons@5.2.2': resolution: {integrity: sha512-v+5dMjYJ6pC4x8IS/4O/GvQBet5GqsXsqJxBiiS6WHZt21EgntpW+q5hxU3dO4q/njiyR8O1JsdLAs3NranoXA==} engines: {node: '>=20.19 <22 || >=22.12'} @@ -7734,14 +7728,6 @@ packages: peerDependencies: '@types/react': '*' - '@sanity/ui@4.0.7': - resolution: {integrity: sha512-hdXAqZDlctHUKVH7UJmQpCgeHefVjDIa3b6QUkCiLxS+tPhlRvNjOJRB32HAftWNw7scS6aJhXlqJp2/KvH8SA==} - engines: {node: '>=22.12'} - peerDependencies: - react: ^19.2 - react-dom: ^19.2 - styled-components: ^6.1 - '@sanity/ui@4.2.7': resolution: {integrity: sha512-P/np7nq69M3bmCjzbs2c60M06R4lga2z68ttFV6VFvMFcyw+3aa3qIb73L37e9GkhQS3vEWjnO1G3tcm5OI8sw==} engines: {node: '>=22.12'} @@ -7773,6 +7759,10 @@ packages: resolution: {integrity: sha512-GPRCdeEfOOOoOiJb+6uOJxOt8sGTmSYfIz/U2/viQurytOOSz8v++fnSVBG6YLhtKhv2FzUIGgvSyPfiJ69FSQ==} engines: {node: '>=20.19 <22 || >=22.12'} + '@sanity/vanilla-extract-integration@0.1.20': + resolution: {integrity: sha512-fIrclslCl0luZL0yEjMwo2l5+NMMw60KrQNCwTMkoZNPtUTj1hx8/silD4IclKrvsIOaVuUG80Qv7BT6muysow==} + engines: {node: '>=20.19 <22 || >=22.12'} + '@sanity/vanilla-extract-rolldown-plugin@0.4.6': resolution: {integrity: sha512-vPFIsyAFBq7rqmY0lToNH2lI5UQTtiWkoMYkuwobVQjV9NECld2aLb6ptwHNUeIbKlQ0P/ow3P73V3hVYizf6g==} engines: {node: '>=20.19 <22 || >=22.12'} @@ -7788,8 +7778,8 @@ packages: peerDependencies: tsdown: ^0.22.5 - '@sanity/vanilla-extract-vite-plugin@0.2.17': - resolution: {integrity: sha512-lCbJMlu44RdHcJhFofe67t66M6ajnjgEpyFJfu2zoDUksyljqSjB7C6YpmBAb9k+nAaaoC6DRwlHeXWhGydRLA==} + '@sanity/vanilla-extract-vite-plugin@0.2.20': + resolution: {integrity: sha512-brZcEk/3S9vofEmolcvT9DPeZ+VPoCfT3ksQA1TsJvqBMyBL16kqh7ZgtDhqmByu/LzX2FkyuvsZOSCuZ4W44w==} engines: {node: '>=20.19 <22 || >=22.12'} peerDependencies: vite: ^8.0.0 @@ -8734,6 +8724,11 @@ packages: cpu: [x64] os: [win32] + '@yuku-parser/binding-android-arm64@0.11.0': + resolution: {integrity: sha512-jqlfj6xjjhC/T81db1HMuzYrCy/rXqFWM0sHLQ63R93CfyApBVKVB8iK5siIaqw5wTs8sGEFN2mM09GNqM1R1g==} + cpu: [arm64] + os: [android] + '@yuku-parser/binding-android-arm64@0.8.7': resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} cpu: [arm64] @@ -8744,6 +8739,11 @@ packages: cpu: [arm64] os: [android] + '@yuku-parser/binding-darwin-arm64@0.11.0': + resolution: {integrity: sha512-kWwHiaiU7VcdFr0MOI+pWZ4NgRo0gGw1ycE5dvhlr6XLMR0YSL+Ov8MseCwGkzUtOZ8cyFCOeXOwCa7mjMffMQ==} + cpu: [arm64] + os: [darwin] + '@yuku-parser/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} cpu: [arm64] @@ -8754,6 +8754,11 @@ packages: cpu: [arm64] os: [darwin] + '@yuku-parser/binding-darwin-x64@0.11.0': + resolution: {integrity: sha512-Gy1pHdOWyXRJMbB0auuTTf+lbDOLuxWxqnU9cpf4vOrozfTDplXNrGuNbLgfZZ9UOgKz9iBnEx2YJ5fTbtvtLQ==} + cpu: [x64] + os: [darwin] + '@yuku-parser/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} cpu: [x64] @@ -8764,6 +8769,11 @@ packages: cpu: [x64] os: [darwin] + '@yuku-parser/binding-freebsd-x64@0.11.0': + resolution: {integrity: sha512-QQ9kdjL/5IknqYYVlbBheQB8ibpbtCQH44KHnz0Iu9c+l4Cbml/bwtV8NntyDe7uOaTpjBbCzlYEdZinT5dw6A==} + cpu: [x64] + os: [freebsd] + '@yuku-parser/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} cpu: [x64] @@ -8774,6 +8784,12 @@ packages: cpu: [x64] os: [freebsd] + '@yuku-parser/binding-linux-arm-gnu@0.11.0': + resolution: {integrity: sha512-F0MNggsSsLIqF1Os4joWikO5VUwmSSHIBUjVDOrJMllQODXfZfyEsLd9rMQbuvqUlXUr1qBMvjaHcJgnF/uaLA==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} cpu: [arm] @@ -8786,6 +8802,12 @@ packages: os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm-musl@0.11.0': + resolution: {integrity: sha512-2QfhRxHPO5hPkblvJNUMyB0PRw/az4Y0YuvLsbcpQtXqPQXL2kL0x8k1MPWtvrWQQPcezsHen3uIhe/2U82QaQ==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} cpu: [arm] @@ -8798,6 +8820,12 @@ packages: os: [linux] libc: [musl] + '@yuku-parser/binding-linux-arm64-gnu@0.11.0': + resolution: {integrity: sha512-6qRv2khbDlIYjWjWjkzxdJTkF4EGQvS+Q5WWwh1azACFUexzCr0gU5Krb/JxvQdTbO6sp9QM3Ybfa7iOD3qthg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} cpu: [arm64] @@ -8810,6 +8838,12 @@ packages: os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm64-musl@0.11.0': + resolution: {integrity: sha512-3olnRYJgIHPtap+Ruc7wlC5Ex5uA2Y7mBECqDLmeUNv0VCt6LmSd5tem5HuItrwREVfPtTmuuFaStYbuE0/Amw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} cpu: [arm64] @@ -8822,6 +8856,12 @@ packages: os: [linux] libc: [musl] + '@yuku-parser/binding-linux-x64-gnu@0.11.0': + resolution: {integrity: sha512-xOoQ/wX1CECcBu+u2uVdZ/5jQpDuyfpMST73AsZV/9rFXHMoV4dzpMvFL/LlcOt0ZvI+ec9dou1qHgLpuWE1gw==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} cpu: [x64] @@ -8834,6 +8874,12 @@ packages: os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-x64-musl@0.11.0': + resolution: {integrity: sha512-rQfgYpctPVlFsEmEdJsgyV/gX38Pih8Jc+UDd5v2ae+Me0ufUSMsqbZAsFv/pYTqYw3ddF+/0UXoutEYbnnuUg==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} cpu: [x64] @@ -8846,6 +8892,11 @@ packages: os: [linux] libc: [musl] + '@yuku-parser/binding-win32-arm64@0.11.0': + resolution: {integrity: sha512-kUhOMR/SKASx9vD+BOkduLQC8nO/0jBygIpnLHrqSwhZocCprtKyRUSiU1hjI9QfVKotmES3ViYY2FALrFsCYQ==} + cpu: [arm64] + os: [win32] + '@yuku-parser/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} cpu: [arm64] @@ -8856,6 +8907,11 @@ packages: cpu: [arm64] os: [win32] + '@yuku-parser/binding-win32-x64@0.11.0': + resolution: {integrity: sha512-+aWJefFA/FBeL/SeC4nYmw3HB2kXWAYHTNY/BXi3N1ex/CVUPJHn3cCYK1T3M31nsO98ENqllNzWfv9arf6X0w==} + cpu: [x64] + os: [win32] + '@yuku-parser/binding-win32-x64@0.8.7': resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} cpu: [x64] @@ -8866,6 +8922,9 @@ packages: cpu: [x64] os: [win32] + '@yuku-toolchain/types@0.11.0': + resolution: {integrity: sha512-AlxUWVHNBTty88DtOSKu77115kjo7i8pWICmiF4CK+6/hEvnOkcOTXVoLtDpko7f5UzVAZsTEm92MV6/KoBmWA==} + '@yuku-toolchain/types@0.8.7': resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} @@ -12026,17 +12085,6 @@ packages: motion-utils@13.3.0: resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} - motion@13.1.0: - resolution: {integrity: sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - motion@13.4.4: resolution: {integrity: sha512-lyX5kpAum2MmigecKWrFdIj3Sjn7pHek2EUrXY0WGZbOjJ1f5NfX0g4KQAb2Bp2EqL0a6dUzse11s1n7OYr5jg==} peerDependencies: @@ -14875,6 +14923,9 @@ packages: yuku-codegen@0.8.7: resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + yuku-parser@0.11.0: + resolution: {integrity: sha512-CIhp3qvcfItVvcNotOEDxtP38Il3LDUbMepZQSfFW1ethwcj/2FuuSeWSc3MmVJP0lUKJySS+5nGKxdxE+aaIg==} + yuku-parser@0.8.7: resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} @@ -19798,7 +19849,7 @@ snapshots: '@publint/pack@0.1.7': dependencies: - tinyexec: 1.3.0 + tinyexec: 1.3.1 '@quansync/fs@1.0.0': dependencies: @@ -21076,10 +21127,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@sanity/icons@5.2.1(react@19.3.0)': - dependencies: - react: 19.3.0 - '@sanity/icons@5.2.2(react@19.3.0)': dependencies: react: 19.3.0 @@ -21598,20 +21645,6 @@ snapshots: transitivePeerDependencies: - vitest - '@sanity/ui@4.0.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3)': - dependencies: - '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0)(react@19.3.0) - '@sanity/color': 3.0.8 - '@sanity/icons': 5.2.2(react@19.3.0) - csstype: 3.2.3 - motion: 13.4.4(react-dom@19.3.0)(react@19.3.0) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - react-is: 19.3.0 - react-refractor: 4.0.0(react@19.3.0) - styled-components: 6.5.3(react-dom@19.3.0)(react@19.3.0) - use-effect-event: 2.0.4(react@19.3.0) - '@sanity/ui@4.2.7(react-dom@19.3.0)(react@19.3.0)(styled-components@6.5.3)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0)(react@19.3.0) @@ -21682,6 +21715,15 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros + '@sanity/vanilla-extract-integration@0.1.20(babel-plugin-macros@3.1.0)': + dependencies: + '@vanilla-extract/css': 1.21.2(babel-plugin-macros@3.1.0) + javascript-stringify: 2.1.0 + rolldown: 1.2.11 + yuku-parser: 0.11.0 + transitivePeerDependencies: + - babel-plugin-macros + '@sanity/vanilla-extract-rolldown-plugin@0.4.6(babel-plugin-macros@3.1.0)(rolldown@1.2.11)': dependencies: '@sanity/vanilla-extract-integration': 0.1.17(babel-plugin-macros@3.1.0) @@ -21699,9 +21741,9 @@ snapshots: - babel-plugin-macros - rolldown - '@sanity/vanilla-extract-vite-plugin@0.2.17(babel-plugin-macros@3.1.0)(vite@8.3.1)': + '@sanity/vanilla-extract-vite-plugin@0.2.20(babel-plugin-macros@3.1.0)(vite@8.3.1)': dependencies: - '@sanity/vanilla-extract-integration': 0.1.17(babel-plugin-macros@3.1.0) + '@sanity/vanilla-extract-integration': 0.1.20(babel-plugin-macros@3.1.0) '@vanilla-extract/css': 1.21.2(babel-plugin-macros@3.1.0) vite: 8.3.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: @@ -22875,78 +22917,116 @@ snapshots: '@yuku-codegen/binding-win32-x64@0.8.7': optional: true + '@yuku-parser/binding-android-arm64@0.11.0': + optional: true + '@yuku-parser/binding-android-arm64@0.8.7': optional: true '@yuku-parser/binding-android-arm64@0.9.3': optional: true + '@yuku-parser/binding-darwin-arm64@0.11.0': + optional: true + '@yuku-parser/binding-darwin-arm64@0.8.7': optional: true '@yuku-parser/binding-darwin-arm64@0.9.3': optional: true + '@yuku-parser/binding-darwin-x64@0.11.0': + optional: true + '@yuku-parser/binding-darwin-x64@0.8.7': optional: true '@yuku-parser/binding-darwin-x64@0.9.3': optional: true + '@yuku-parser/binding-freebsd-x64@0.11.0': + optional: true + '@yuku-parser/binding-freebsd-x64@0.8.7': optional: true '@yuku-parser/binding-freebsd-x64@0.9.3': optional: true + '@yuku-parser/binding-linux-arm-gnu@0.11.0': + optional: true + '@yuku-parser/binding-linux-arm-gnu@0.8.7': optional: true '@yuku-parser/binding-linux-arm-gnu@0.9.3': optional: true + '@yuku-parser/binding-linux-arm-musl@0.11.0': + optional: true + '@yuku-parser/binding-linux-arm-musl@0.8.7': optional: true '@yuku-parser/binding-linux-arm-musl@0.9.3': optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.11.0': + optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': optional: true '@yuku-parser/binding-linux-arm64-gnu@0.9.3': optional: true + '@yuku-parser/binding-linux-arm64-musl@0.11.0': + optional: true + '@yuku-parser/binding-linux-arm64-musl@0.8.7': optional: true '@yuku-parser/binding-linux-arm64-musl@0.9.3': optional: true + '@yuku-parser/binding-linux-x64-gnu@0.11.0': + optional: true + '@yuku-parser/binding-linux-x64-gnu@0.8.7': optional: true '@yuku-parser/binding-linux-x64-gnu@0.9.3': optional: true + '@yuku-parser/binding-linux-x64-musl@0.11.0': + optional: true + '@yuku-parser/binding-linux-x64-musl@0.8.7': optional: true '@yuku-parser/binding-linux-x64-musl@0.9.3': optional: true + '@yuku-parser/binding-win32-arm64@0.11.0': + optional: true + '@yuku-parser/binding-win32-arm64@0.8.7': optional: true '@yuku-parser/binding-win32-arm64@0.9.3': optional: true + '@yuku-parser/binding-win32-x64@0.11.0': + optional: true + '@yuku-parser/binding-win32-x64@0.8.7': optional: true '@yuku-parser/binding-win32-x64@0.9.3': optional: true + '@yuku-toolchain/types@0.11.0': {} + '@yuku-toolchain/types@0.8.7': {} '@yuku-toolchain/types@0.9.3': {} @@ -26307,14 +26387,6 @@ snapshots: motion-utils@13.3.0: {} - motion@13.1.0(react-dom@19.3.0)(react@19.3.0): - dependencies: - framer-motion: 13.4.4(react-dom@19.3.0)(react@19.3.0) - tslib: 2.8.1 - optionalDependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - motion@13.4.4(react-dom@19.3.0)(react@19.3.0): dependencies: framer-motion: 13.4.4(react-dom@19.3.0)(react@19.3.0) @@ -30186,6 +30258,23 @@ snapshots: '@yuku-codegen/binding-win32-arm64': 0.8.7 '@yuku-codegen/binding-win32-x64': 0.8.7 + yuku-parser@0.11.0: + dependencies: + '@yuku-toolchain/types': 0.11.0 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.11.0 + '@yuku-parser/binding-darwin-arm64': 0.11.0 + '@yuku-parser/binding-darwin-x64': 0.11.0 + '@yuku-parser/binding-freebsd-x64': 0.11.0 + '@yuku-parser/binding-linux-arm-gnu': 0.11.0 + '@yuku-parser/binding-linux-arm-musl': 0.11.0 + '@yuku-parser/binding-linux-arm64-gnu': 0.11.0 + '@yuku-parser/binding-linux-arm64-musl': 0.11.0 + '@yuku-parser/binding-linux-x64-gnu': 0.11.0 + '@yuku-parser/binding-linux-x64-musl': 0.11.0 + '@yuku-parser/binding-win32-arm64': 0.11.0 + '@yuku-parser/binding-win32-x64': 0.11.0 + yuku-parser@0.8.7: dependencies: '@yuku-toolchain/types': 0.8.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ea01e96b72..ef9c31482b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,7 +28,7 @@ catalog: '@sanity/asset-utils': ^2.3.0 '@sanity/client': ^8.6.1 '@sanity/color': ^3.0.8 - '@sanity/icons': ^5.2.1 + '@sanity/icons': ^5.2.2 '@sanity/image-url': ^2.1.1 '@sanity/mutator': ^6.16.0 '@sanity/pkg-utils': ^12.3.4 @@ -38,10 +38,10 @@ catalog: '@sanity/tsconfig': ^2.2.1 '@sanity/tsdown-config': ^0.27.1 '@sanity/types': ^6.16.0 - '@sanity/ui': ^4.0.7 + '@sanity/ui': ^4.2.7 '@sanity/util': ^6.16.0 '@sanity/uuid': ^3.0.3 - '@sanity/vanilla-extract-vite-plugin': ^0.2.17 + '@sanity/vanilla-extract-vite-plugin': ^0.2.20 '@sanity/vision': ^6.16.0 '@testing-library/jest-dom': ^7.0.1 '@testing-library/react': ^16.3.3 @@ -61,7 +61,7 @@ catalog: jsdom: ^29.1.1 lexorank: ^1.0.5 lodash-es: ^4.18.1 - motion: ^13.1.0 + motion: ^13.4.4 nanoid: ^6.0.1 oxc-transform-react: ^0.147.0 oxfmt: ^0.63.0 From 3334ac87668e2cf5c40898791630d3dc41ee419d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 21:32:14 +0000 Subject: [PATCH 03/10] feat(themer): move @sanity/themer from sanity-io/ui to plugins/@sanity/themer Copy `packages/themer` from the latest sanity-io/ui main (the source of @sanity/themer@0.8.0), keeping its version and changelog so the next release continues at 0.8.1: - package.json follows the monorepo template (plugins repository links, `build`/`prepack` scripts) with the published exports, `sideEffects` and dependency ranges; @sanity/themer-legacy, which moves to sanity-io/themer, is the published ^0.2.1, and React stays ^19.3 as the tool needs `ViewTransition` - tsdown/vitest configs from the templates, with themer's own build options (explicit entries, React Compiler target 19, unminified bundle.css without the light-dark() polyfill) and a package exports test - tsconfig extends @sanity/tsconfig/strictest without `noUncheckedIndexedAccess`, which sanity-io/ui never enabled (like sanity-plugin-graph-view and plugin-kit); type-only imports are marked for `verbatimModuleSyntax` - the build output matches the published 0.8.0: identical JavaScript (modulo chunk hashes) and bundle.css - xstate, @xstate/react and scroll-into-view-if-needed move to the catalog, now that other plugins share them - the test studio uses the workspace package instead of ^0.8.0 from npm --- .changeset/dashboard-widget-vercel-xstate.md | 5 + .changeset/themer-plugins-monorepo.md | 7 + README.md | 1 + dev/test-studio/package.json | 2 +- knip.jsonc | 3 + plugins/@sanity/themer/CHANGELOG.md | 245 ++++++++ plugins/@sanity/themer/README.md | 86 +++ plugins/@sanity/themer/package.json | 106 ++++ plugins/@sanity/themer/src/index.test.ts | 33 + plugins/@sanity/themer/src/index.ts | 8 + plugins/@sanity/themer/src/legacy/index.ts | 96 +++ .../themer/src/legacy/reexport.test.ts | 27 + plugins/@sanity/themer/src/lib/mix.ts | 13 + .../@sanity/themer/src/theme/buildPalette.ts | 267 +++++++++ .../@sanity/themer/src/theme/buildTheme.ts | 72 +++ plugins/@sanity/themer/src/theme/hsl.ts | 137 +++++ plugins/@sanity/themer/src/theme/options.ts | 206 +++++++ plugins/@sanity/themer/src/theme/presets.ts | 83 +++ .../@sanity/themer/src/theme/theme.test.ts | 274 +++++++++ .../src/tool/AnimatedColorWheelIcon.tsx | 93 +++ .../themer/src/tool/ImageFileButton.tsx | 41 ++ .../src/tool/ImagePaletteSection.css.ts | 126 ++++ .../themer/src/tool/ImagePaletteSection.tsx | 271 +++++++++ .../themer/src/tool/PasteThemeDialog.tsx | 83 +++ .../themer/src/tool/RemovedThemes.css.ts | 6 + .../@sanity/themer/src/tool/RemovedThemes.tsx | 54 ++ .../themer/src/tool/ResizableSidebar.css.ts | 77 +++ .../themer/src/tool/ResizableSidebar.tsx | 131 ++++ .../@sanity/themer/src/tool/ScrollArea.css.ts | 6 + .../@sanity/themer/src/tool/ScrollArea.tsx | 97 +++ .../@sanity/themer/src/tool/ThemeCard.css.ts | 73 +++ plugins/@sanity/themer/src/tool/ThemeCard.tsx | 180 ++++++ .../themer/src/tool/ThemeEditor.css.ts | 40 ++ .../@sanity/themer/src/tool/ThemeEditor.tsx | 406 +++++++++++++ .../@sanity/themer/src/tool/ThemeList.css.ts | 39 ++ plugins/@sanity/themer/src/tool/ThemeList.tsx | 116 ++++ .../themer/src/tool/ThemePreview.css.ts | 165 +++++ .../@sanity/themer/src/tool/ThemePreview.tsx | 121 ++++ .../themer/src/tool/ThemeSnippetDialog.tsx | 75 +++ .../themer/src/tool/ThemeThumbnail.tsx | 18 + .../themer/src/tool/ThemerLayout.css.ts | 193 ++++++ .../@sanity/themer/src/tool/ThemerLayout.tsx | 369 ++++++++++++ .../@sanity/themer/src/tool/ThemerNavbar.tsx | 171 ++++++ .../themer/src/tool/ThemerSidebar.css.ts | 6 + .../@sanity/themer/src/tool/ThemerSidebar.tsx | 105 ++++ .../@sanity/themer/src/tool/TooltipButton.tsx | 24 + .../themer/src/tool/colorWheel.test.ts | 133 ++++ plugins/@sanity/themer/src/tool/colorWheel.ts | 89 +++ plugins/@sanity/themer/src/tool/context.ts | 62 ++ plugins/@sanity/themer/src/tool/imageFile.ts | 65 ++ .../themer/src/tool/imagePalette.test.ts | 344 +++++++++++ .../@sanity/themer/src/tool/imagePalette.ts | 498 +++++++++++++++ plugins/@sanity/themer/src/tool/index.ts | 1 + .../@sanity/themer/src/tool/machine.test.ts | 524 ++++++++++++++++ plugins/@sanity/themer/src/tool/machine.ts | 567 ++++++++++++++++++ plugins/@sanity/themer/src/tool/options.ts | 63 ++ .../@sanity/themer/src/tool/plugin.test.ts | 118 ++++ plugins/@sanity/themer/src/tool/plugin.tsx | 79 +++ plugins/@sanity/themer/src/tool/share.test.ts | 71 +++ plugins/@sanity/themer/src/tool/share.ts | 161 +++++ .../@sanity/themer/src/tool/sidebarWidth.ts | 11 + plugins/@sanity/themer/src/tool/snippet.ts | 47 ++ .../@sanity/themer/src/tool/storage.test.ts | 261 ++++++++ plugins/@sanity/themer/src/tool/storage.ts | 287 +++++++++ .../@sanity/themer/src/tool/themes.test.ts | 131 ++++ plugins/@sanity/themer/src/tool/themes.ts | 154 +++++ .../themer/src/tool/useImagePalette.ts | 71 +++ .../src/tool/usePrefersReducedMotion.ts | 29 + .../themer/src/tool/useStudioNavbarHeight.ts | 65 ++ .../@sanity/themer/src/tool/useThemeCodes.ts | 101 ++++ .../src/tool/useTypedDeferredValue.test.tsx | 122 ++++ .../themer/src/tool/useTypedDeferredValue.ts | 37 ++ plugins/@sanity/themer/tsconfig.json | 8 + plugins/@sanity/themer/tsdown.config.ts | 34 ++ plugins/@sanity/themer/vitest.config.ts | 14 + .../package.json | 4 +- plugins/sanity-plugin-mux-input/package.json | 2 +- pnpm-lock.yaml | 134 +++-- pnpm-workspace.yaml | 3 + 79 files changed, 9006 insertions(+), 41 deletions(-) create mode 100644 .changeset/dashboard-widget-vercel-xstate.md create mode 100644 .changeset/themer-plugins-monorepo.md create mode 100644 plugins/@sanity/themer/CHANGELOG.md create mode 100644 plugins/@sanity/themer/README.md create mode 100644 plugins/@sanity/themer/package.json create mode 100644 plugins/@sanity/themer/src/index.test.ts create mode 100644 plugins/@sanity/themer/src/index.ts create mode 100644 plugins/@sanity/themer/src/legacy/index.ts create mode 100644 plugins/@sanity/themer/src/legacy/reexport.test.ts create mode 100644 plugins/@sanity/themer/src/lib/mix.ts create mode 100644 plugins/@sanity/themer/src/theme/buildPalette.ts create mode 100644 plugins/@sanity/themer/src/theme/buildTheme.ts create mode 100644 plugins/@sanity/themer/src/theme/hsl.ts create mode 100644 plugins/@sanity/themer/src/theme/options.ts create mode 100644 plugins/@sanity/themer/src/theme/presets.ts create mode 100644 plugins/@sanity/themer/src/theme/theme.test.ts create mode 100644 plugins/@sanity/themer/src/tool/AnimatedColorWheelIcon.tsx create mode 100644 plugins/@sanity/themer/src/tool/ImageFileButton.tsx create mode 100644 plugins/@sanity/themer/src/tool/ImagePaletteSection.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ImagePaletteSection.tsx create mode 100644 plugins/@sanity/themer/src/tool/PasteThemeDialog.tsx create mode 100644 plugins/@sanity/themer/src/tool/RemovedThemes.css.ts create mode 100644 plugins/@sanity/themer/src/tool/RemovedThemes.tsx create mode 100644 plugins/@sanity/themer/src/tool/ResizableSidebar.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ResizableSidebar.tsx create mode 100644 plugins/@sanity/themer/src/tool/ScrollArea.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ScrollArea.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemeCard.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemeCard.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemeEditor.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemeEditor.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemeList.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemeList.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemePreview.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemePreview.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemeSnippetDialog.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemeThumbnail.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemerLayout.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemerLayout.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemerNavbar.tsx create mode 100644 plugins/@sanity/themer/src/tool/ThemerSidebar.css.ts create mode 100644 plugins/@sanity/themer/src/tool/ThemerSidebar.tsx create mode 100644 plugins/@sanity/themer/src/tool/TooltipButton.tsx create mode 100644 plugins/@sanity/themer/src/tool/colorWheel.test.ts create mode 100644 plugins/@sanity/themer/src/tool/colorWheel.ts create mode 100644 plugins/@sanity/themer/src/tool/context.ts create mode 100644 plugins/@sanity/themer/src/tool/imageFile.ts create mode 100644 plugins/@sanity/themer/src/tool/imagePalette.test.ts create mode 100644 plugins/@sanity/themer/src/tool/imagePalette.ts create mode 100644 plugins/@sanity/themer/src/tool/index.ts create mode 100644 plugins/@sanity/themer/src/tool/machine.test.ts create mode 100644 plugins/@sanity/themer/src/tool/machine.ts create mode 100644 plugins/@sanity/themer/src/tool/options.ts create mode 100644 plugins/@sanity/themer/src/tool/plugin.test.ts create mode 100644 plugins/@sanity/themer/src/tool/plugin.tsx create mode 100644 plugins/@sanity/themer/src/tool/share.test.ts create mode 100644 plugins/@sanity/themer/src/tool/share.ts create mode 100644 plugins/@sanity/themer/src/tool/sidebarWidth.ts create mode 100644 plugins/@sanity/themer/src/tool/snippet.ts create mode 100644 plugins/@sanity/themer/src/tool/storage.test.ts create mode 100644 plugins/@sanity/themer/src/tool/storage.ts create mode 100644 plugins/@sanity/themer/src/tool/themes.test.ts create mode 100644 plugins/@sanity/themer/src/tool/themes.ts create mode 100644 plugins/@sanity/themer/src/tool/useImagePalette.ts create mode 100644 plugins/@sanity/themer/src/tool/usePrefersReducedMotion.ts create mode 100644 plugins/@sanity/themer/src/tool/useStudioNavbarHeight.ts create mode 100644 plugins/@sanity/themer/src/tool/useThemeCodes.ts create mode 100644 plugins/@sanity/themer/src/tool/useTypedDeferredValue.test.tsx create mode 100644 plugins/@sanity/themer/src/tool/useTypedDeferredValue.ts create mode 100644 plugins/@sanity/themer/tsconfig.json create mode 100644 plugins/@sanity/themer/tsdown.config.ts create mode 100644 plugins/@sanity/themer/vitest.config.ts diff --git a/.changeset/dashboard-widget-vercel-xstate.md b/.changeset/dashboard-widget-vercel-xstate.md new file mode 100644 index 0000000000..d00fab8cc0 --- /dev/null +++ b/.changeset/dashboard-widget-vercel-xstate.md @@ -0,0 +1,5 @@ +--- +'sanity-plugin-dashboard-widget-vercel': patch +--- + +Update `xstate` to ^5.33.2 diff --git a/.changeset/themer-plugins-monorepo.md b/.changeset/themer-plugins-monorepo.md new file mode 100644 index 0000000000..120bc33ed7 --- /dev/null +++ b/.changeset/themer-plugins-monorepo.md @@ -0,0 +1,7 @@ +--- +'@sanity/themer': patch +--- + +author: @stipsan + +Move `@sanity/themer` from the sanity-io/ui monorepo to the Sanity plugins monorepo. The package builds to the same JavaScript and CSS, with the same exports and dependency ranges; issues and pull requests now go to [sanity-io/plugins](https://github.com/sanity-io/plugins). diff --git a/README.md b/README.md index c512075e0f..2c67f0159b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Sessions can be compared in the DevTools UI to diff bundle changes between build | [`@sanity/sfcc`](./plugins/@sanity/sfcc) | Salesforce Commerce Cloud integration with synced product data | | [`@sanity/studio-secrets`](./plugins/@sanity/studio-secrets) | Manage Studio secrets at runtime | | [`@sanity/table`](./plugins/@sanity/table) | Table schema type and input component for Sanity Studio | +| [`@sanity/themer`](./plugins/@sanity/themer) | Generate Studio themes from a few colors, with a themer Studio tool | | [`@sanity/vercel-protection-bypass`](./plugins/@sanity/vercel-protection-bypass) | Setup tool for Vercel Deployment Protection in previews | | [`sanity-plugin-aprimo`](./plugins/sanity-plugin-aprimo) | Aprimo asset selector integration | | [`sanity-plugin-asset-source-unsplash`](./plugins/sanity-plugin-asset-source-unsplash) | Use Unsplash images directly in Sanity Studio | diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index 6558d832f5..6059fdd35b 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -35,7 +35,7 @@ "@sanity/sfcc": "workspace:*", "@sanity/studio-secrets": "workspace:*", "@sanity/table": "workspace:*", - "@sanity/themer": "^0.8.0", + "@sanity/themer": "workspace:*", "@sanity/ui": "catalog:", "@sanity/vanilla-extract-vite-plugin": "catalog:", "@sanity/vercel-protection-bypass": "workspace:*", diff --git a/knip.jsonc b/knip.jsonc index 8c74f23a7f..0d67e47636 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -136,6 +136,9 @@ "plugins/@sanity/block-insert-picker": { "project": ["src/**/*.{ts,tsx}"], }, + "plugins/@sanity/themer": { + "project": ["src/**/*.{ts,tsx}"], + }, // add new plugin workspaces here "plugins/@sanity/embeddings-index-ui": { "project": ["src/**/*.{ts,tsx}"], diff --git a/plugins/@sanity/themer/CHANGELOG.md b/plugins/@sanity/themer/CHANGELOG.md new file mode 100644 index 0000000000..f81629570a --- /dev/null +++ b/plugins/@sanity/themer/CHANGELOG.md @@ -0,0 +1,245 @@ +# @sanity/themer + +## 0.8.0 + +### Minor Changes + +- [#3011](https://github.com/sanity-io/ui/pull/3011) [`4c20a30`](https://github.com/sanity-io/ui/commit/4c20a30ff5f9493306ac2914fa57e3289af23fd9) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool's sidebar loads the first time it's needed instead of with the Studio: `@sanity/themer/tool` starts out with just the navbar toggle and the layout that applies the picked theme, and the theme list, the editor and the snippet dialog — with `react-refractor` and the other dependencies only they use — load on demand, starting as the pointer moves onto the navbar toggle or it gets focus. Pressed before they have loaded, the toggle's color wheel goes round, lap after lap, while the Studio stays as it is; from then on the sidebar stays mounted while closed, as before, and opens right away. + +- [#3012](https://github.com/sanity-io/ui/pull/3012) [`afbb0f0`](https://github.com/sanity-io/ui/commit/afbb0f04d98c4c06dd475522c23cf3d756d6c193) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool introduces itself: the color wheel icon of its navbar toggle plays an animation on hover — the eight slices of the wheel pop in one at a time with the palette's hues in rainbow order, clockwise from 12 o'clock like the segments of a classic spinner, the wheel spins once as the 6 o'clock slice pops in, and the colors pop out the same way round, easing out from the speed the spin reached as if it kept turning for a second lap. Hovering the button plays it every time; hovering the Studio navbar plays it once per page load for anyone who has never opened the sidebar — opening it (from the topbar button or the narrow-screen menu) is remembered in `localStorage`, and the navbar stops introducing the tool. With `prefers-reduced-motion: reduce` the wheel keeps still and only the colors come and go. The navbar action also renders the toggle as a stable component instead of remounting it on every navbar render. + +### Patch Changes + +- [#3022](https://github.com/sanity-io/ui/pull/3022) [`3048289`](https://github.com/sanity-io/ui/commit/3048289c070a2bf5c81c3c8735f8b335214108b2) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency motion to ^13.4.4 +- Updated dependencies [[`3048289`](https://github.com/sanity-io/ui/commit/3048289c070a2bf5c81c3c8735f8b335214108b2)]: + - @sanity/ui@4.2.7 + +## 0.7.1 + +### Patch Changes + +- [#3010](https://github.com/sanity-io/ui/pull/3010) [`ff01934`](https://github.com/sanity-io/ui/commit/ff0193453bb31a1ac31785cbdb33ff1996d77db1) Thanks [@stipsan](https://github.com/stipsan)! - `@sanity/themer/bundle.css` keeps its `color-scheme` declarations as authored: Lightning CSS no longer adds the `--lightningcss-light`/`--lightningcss-dark` custom properties of its `light-dark()` polyfill next to them. The stylesheet also ships unminified, so it reads cleanly when comparing published versions. + +- [#3015](https://github.com/sanity-io/ui/pull/3015) [`f17f4c0`](https://github.com/sanity-io/ui/commit/f17f4c05c44f6de632d34f95358f54b1b49e4d72) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool's motions keep time with `view-transition-name`s the Studio gives its own elements — an avatar named so it moves as one piece instead of stretching along with the navbar's snapshot. Every view transition the layout starts now carries the `sanity-themer` transition type, and while one runs every group on the page shares the layout's duration and easing instead of the browser's default quarter second and `ease`, which left them out of step with the navbar; the Studio's own transitions keep their own. +- Updated dependencies [[`0042a4a`](https://github.com/sanity-io/ui/commit/0042a4afa36f0055d02becd746d9e0d03d454d32)]: + - @sanity/themer-legacy@0.2.1 + +## 0.7.0 + +### Minor Changes + +- [#2995](https://github.com/sanity-io/ui/pull/2995) [`50ba94f`](https://github.com/sanity-io/ui/commit/50ba94f62a4d760ac59d1d51bd4fdba002234978) Thanks [@stipsan](https://github.com/stipsan)! - Themes can be shared as codes: "Copy to share" in a theme's menu puts a short code on the clipboard — the theme's title and the colors and contrasts of both schemes packed into bytes and base64url'd, one word of about 30 bytes plus the title — and the paste button in the list adds the theme from a code, straight from the clipboard where the browser allows and through a dialog otherwise; a code pasted anywhere in the Studio while the list shows is added too. Imported themes are applied right away without opening the editor. Applying another theme cross-fades the Studio and the sidebar to it, and with `prefers-reduced-motion: reduce` the tool runs no view transitions at all. The sidebar's buttons are sized like the Studio's own. + +## 0.6.1 + +### Patch Changes + +- [#2996](https://github.com/sanity-io/ui/pull/2996) [`8cb631a`](https://github.com/sanity-io/ui/commit/8cb631ae86bdd75046e3abe7fca2213c5520b058) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency motion to ^13.4.3 +- Updated dependencies [[`8cb631a`](https://github.com/sanity-io/ui/commit/8cb631ae86bdd75046e3abe7fca2213c5520b058)]: + - @sanity/ui@4.2.6 + +## 0.6.0 + +### Minor Changes + +- [#2984](https://github.com/sanity-io/ui/pull/2984) [`196ecf0`](https://github.com/sanity-io/ui/commit/196ecf01111f8216f4282858cd0c403aad2e0382) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool's split preview keeps the Studio next to the sidebar in the appearance the Studio is set to — light or dark from the appearance menu, with the picked theme — and slides a copy in the opposite scheme in from off screen on the far side, through React's `ViewTransition`: the copy slides in without fading, the Studio it makes room for cross-fades between its two widths at full height, and the sidebar stays put. `@sanity/themer` now requires React 19.3 for this. The tool works on small screens too: the sidebar covers the Studio instead of standing next to it, the split preview stacks the two copies, and the theme cards flow into more columns when the sidebar is wide enough. + +- [#2987](https://github.com/sanity-io/ui/pull/2987) [`2660338`](https://github.com/sanity-io/ui/commit/26603386b585c6b567c0a4e036406081e2137240) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool is styled with vanilla-extract instead of styled-components: its styles ship as the `@sanity/themer/bundle.css` stylesheet, which `@sanity/themer/tool` imports itself (the `node` export condition resolves it to a no-op shim), and `styled-components` is no longer a peer dependency. The sidebar header has its bottom border back and takes the height of the Studio navbar, so the two line up — whatever height the Studio version, breakpoint or a custom navbar gives it. The sidebar slides in from its edge and out to it, the way the split copy does on the far side, and closing it ends the split preview with it. With `prefers-reduced-motion: reduce` the sidebar and the split preview switch layouts without their view transitions animating. The theme cards drag into any order — across the grid too — with the others making room, the card menu moves a theme up or down, and the order persists; the grid flows into two columns from a third of the way into widening the sidebar instead of most of the way. Coming back to the list scrolls the applied theme into view. The sidebar stays mounted while closed (an `Activity`), so it keeps its state and opens warmed up, and the split copy is kept the same way while the sidebar is open. + +### Patch Changes + +- Updated dependencies [[`fde1653`](https://github.com/sanity-io/ui/commit/fde16533056cab695eb9ff0cd52db8c3c023ed25)]: + - @sanity/ui@4.2.5 + +## 0.5.0 + +### Minor Changes + +- [#2982](https://github.com/sanity-io/ui/pull/2982) [`fae7826`](https://github.com/sanity-io/ui/commit/fae7826a3d1741cf42e7b77b3f983fc41b572fe1) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool can preview the Studio in light and dark side by side: the split-screen toggle in the sidebar header renders the whole Studio twice, light on the left and dark on the right, in sync while you navigate, and the editor marks both scheme cards as shown. The sidebar now sits next to the whole Studio, navbar included, rather than next to the active tool, so it renders once and stays put when the preview splits. + +### Patch Changes + +- [#2982](https://github.com/sanity-io/ui/pull/2982) [`fae7826`](https://github.com/sanity-io/ui/commit/fae7826a3d1741cf42e7b77b3f983fc41b572fe1) Thanks [@stipsan](https://github.com/stipsan)! - Themer tool polish: the editor's light and dark mode cards run edge to edge with only a border between them, in the sidebar's own scheme rather than each painted in the scheme it edits — the "Active" badge marks the scheme the Studio is showing — and the menu button on a theme card stays dark, as it sits on the dark half of the thumbnail. +- Updated dependencies [[`0b080bd`](https://github.com/sanity-io/ui/commit/0b080bd4e378bf89b90ec1c22be8c741e9745a54)]: + - @sanity/themer-legacy@0.2.0 + +## 0.4.0 + +### Minor Changes + +- [#2955](https://github.com/sanity-io/ui/pull/2955) [`f2cd6b2`](https://github.com/sanity-io/ui/commit/f2cd6b290577ca8872e9893ad1f389b969510c5c) Thanks [@stipsan](https://github.com/stipsan)! - The themer tool can take a theme's colors from an image, on device: pick an image (or drop one on the editor's image palette card) and it is drawn onto a canvas and its palette extracted right there — vibrant, muted and their light and dark variants plus the dominant color, the way Sanity's image pipeline derives `metadata.palette` — without uploading anything. The vibrant color becomes the accent of both schemes — darkened or lightened along its hue until a button label reaches WCAG AA contrast on it — the muted one the text color, and the light and dark muted colors tint the backgrounds of their scheme. The palette stays with the theme: the image shows next to its swatches for the session, and a row of half-size previews offers the theme built around each swatch (muted, vibrant and their light and dark variants) plus an "I'm feeling lucky" button that picks an interesting one at random. The list has an "add a theme from an image" button next to "Add theme", which names the theme after the file. + +- [#2955](https://github.com/sanity-io/ui/pull/2955) [`f2cd6b2`](https://github.com/sanity-io/ui/commit/f2cd6b290577ca8872e9893ad1f389b969510c5c) Thanks [@stipsan](https://github.com/stipsan)! - **Breaking:** the root `buildTheme` and `buildPalette` options are grouped by color scheme, so the light and the dark scheme can differ in every color rather than only their background: + + ```ts + // Before + buildTheme({accent: '#1cb485', text: '#5c9199', background: {dark: '#0d1415', light: '#fcfdfd'}}) + + // After + buildTheme({ + light: {accent: '#1cb485', text: '#5c9199', background: '#fcfdfd'}, + dark: {accent: '#1cb485', text: '#5c9199', background: '#0d1415'}, + }) + ``` + + Each scheme takes an optional `accent`, `text`, `background` and `contrast`, and both schemes are optional too: whatever is omitted falls back to the stock Studio colors, so `buildTheme({})` matches `buildTheme()` from `@sanity/ui/theme`. `buildPalette` returns a palette per scheme (`{light, dark}`), `presets` carry their colors per scheme, and passing the old flat shape throws a `TypeError` that points at the new one. The themer tool edits themes scheme by scheme — a light mode and a dark mode card with pickers and a contrast slider each, the scheme the Studio is showing marked as active — and its stored themes and legacy drafts are converted from the flat shape automatically. + +- [#2955](https://github.com/sanity-io/ui/pull/2955) [`f2cd6b2`](https://github.com/sanity-io/ui/commit/f2cd6b290577ca8872e9893ad1f389b969510c5c) Thanks [@stipsan](https://github.com/stipsan)! - Redesign the `themerTool` sidebar around themes rather than a single draft. The narrower sidebar lists the configured theme, the presets and your own themes in one column of cards, each with a tiny lo-fi preview of a Studio — navbar, document list and document form — in both color schemes at once, split like the "Auto" appearance preview in macOS, with the title below. Picking a card applies the theme live to the whole Studio. Each flow is now its own: add a theme (or duplicate a preset to start from it), edit your own themes with the accent/text/background pickers and the contrast slider, remove themes, and restore removed ones — removed presets and custom themes alike — from a dedicated view. The `buildTheme` snippet moved out of the sidebar into a dialog behind a header button, so the sidebar no longer resizes as the snippet changes. A draft from the previous version is migrated into a custom theme. The tool's state — the open sidebar, the flow it is in and the themes — is modeled as an XState machine, which adds `xstate` and `@xstate/react` (the versions the Studio already ships) to the package's dependencies. + +## 0.3.16 + +### Patch Changes + +- [#2960](https://github.com/sanity-io/ui/pull/2960) [`5634e5f`](https://github.com/sanity-io/ui/commit/5634e5f2d19350ea373f530c2d461982b1d54f3d) Thanks [@stipsan](https://github.com/stipsan)! - Deprecate `@sanity/themer/legacy`: the legacy generator moved to the new `@sanity/themer-legacy` package, and the subpath now re-exports it unchanged (same functions, same generated colors). Import from `@sanity/themer-legacy` instead — the subpath will be removed in `@sanity/themer@1.0`. +- Updated dependencies [[`a9fb87c`](https://github.com/sanity-io/ui/commit/a9fb87cad74344fa6d1600afd017431b884a7af7), [`5634e5f`](https://github.com/sanity-io/ui/commit/5634e5f2d19350ea373f530c2d461982b1d54f3d)]: + - @sanity/ui@4.2.4 + - @sanity/themer-legacy@0.1.0 + +## 0.3.15 + +### Patch Changes + +- Updated dependencies [[`1d0e8de`](https://github.com/sanity-io/ui/commit/1d0e8de500c2f795830be0a9b57289e41620d257)]: + - @sanity/ui@4.2.3 + +## 0.3.14 + +### Patch Changes + +- Updated dependencies [[`5f3b645`](https://github.com/sanity-io/ui/commit/5f3b6455ec44974163a1604314e36930ddafb2c5)]: + - @sanity/ui@4.2.2 + +## 0.3.13 + +### Patch Changes + +- Updated dependencies [[`7766a79`](https://github.com/sanity-io/ui/commit/7766a79e611de375ad964c0c486f0e8b1883f2e1), [`63a6ca0`](https://github.com/sanity-io/ui/commit/63a6ca0dcfffd523e8f2ca8beae7a10aa7e2202d), [`9c7a1a7`](https://github.com/sanity-io/ui/commit/9c7a1a776494dc9d8dc4b26a34be56357d5e0421), [`7056449`](https://github.com/sanity-io/ui/commit/7056449f9137e7f0681bf247c82f11acec16524d), [`8c64db2`](https://github.com/sanity-io/ui/commit/8c64db2eeed2adf57adbec0b71aa8a637c5331b4)]: + - @sanity/ui@4.2.1 + - @sanity/icons@5.2.2 + +## 0.3.12 + +### Patch Changes + +- Updated dependencies [[`0128e47`](https://github.com/sanity-io/ui/commit/0128e4725b37e11d305f454dab4e55d8840c5695), [`4b81730`](https://github.com/sanity-io/ui/commit/4b8173036ab1133a8ff6099ebbe5b48d08d2957f)]: + - @sanity/ui@4.2.0 + +## 0.3.11 + +### Patch Changes + +- Updated dependencies [[`c9141df`](https://github.com/sanity-io/ui/commit/c9141df70b3c230a625c9531f66c2f9cbd572ee3), [`340baad`](https://github.com/sanity-io/ui/commit/340baad921b81721763cafc646a70042eed68adb)]: + - @sanity/ui@4.1.1 + +## 0.3.10 + +### Patch Changes + +- Updated dependencies [[`b2e6cc1`](https://github.com/sanity-io/ui/commit/b2e6cc1377d8ecdbeb10be44c2539fecbb2d49da), [`7795b61`](https://github.com/sanity-io/ui/commit/7795b614dd3fbc77fa53a764ad81d11f4c3350be)]: + - @sanity/ui@4.1.0 + +## 0.3.9 + +### Patch Changes + +- Updated dependencies [[`7258b02`](https://github.com/sanity-io/ui/commit/7258b02024d233c4270c59a55f813574796006c5), [`80d25c2`](https://github.com/sanity-io/ui/commit/80d25c2e4ebb2b6bb5c82eded7b0329663da8759)]: + - @sanity/ui@4.0.7 + +## 0.3.8 + +### Patch Changes + +- Updated dependencies [[`2dc0627`](https://github.com/sanity-io/ui/commit/2dc06270810e696ebbc2aeb4729cf3eba7c7ec28), [`63ed976`](https://github.com/sanity-io/ui/commit/63ed97631eba7989a6d647a039dfc7df4abe143a)]: + - @sanity/ui@4.0.6 + +## 0.3.7 + +### Patch Changes + +- [#2737](https://github.com/sanity-io/ui/pull/2737) [`465b379`](https://github.com/sanity-io/ui/commit/465b379531cea0fefb668ccddcd319851802d1cd) Thanks [@stipsan](https://github.com/stipsan)! - Build the published dist with the React Compiler running on `oxc-transform-react` (the native Rust port, via `@sanity/tsdown-config`'s new `reactCompiler.transform: 'oxc'`) instead of `babel-plugin-react-compiler`. The output is functionally equivalent — the Rust port tracks the latest React Compiler release, so memo-cache slot allocation differs slightly in places. + +- Updated dependencies [[`465b379`](https://github.com/sanity-io/ui/commit/465b379531cea0fefb668ccddcd319851802d1cd)]: + - @sanity/ui@4.0.5 + +## 0.3.6 + +### Patch Changes + +- [#2729](https://github.com/sanity-io/ui/pull/2729) [`4f9e30f`](https://github.com/sanity-io/ui/commit/4f9e30f892f6f3fe7d7997280b1ba8b9e84c93d4) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update sanity monorepo to ^6.10.1 + +- Updated dependencies [[`3970a7e`](https://github.com/sanity-io/ui/commit/3970a7e6dd0b4dc53d78eca6ed789cf92e39ad7a)]: + - @sanity/ui@4.0.4 + +## 0.3.5 + +### Patch Changes + +- Updated dependencies [[`e93ebeb`](https://github.com/sanity-io/ui/commit/e93ebebc030d3f6e4b6872496f8ccc30247138f6)]: + - @sanity/ui@4.0.3 + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [[`adf0894`](https://github.com/sanity-io/ui/commit/adf0894ba356d15b2af522f4f86fe532b09b6f21)]: + - @sanity/ui@4.0.2 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [[`7dd3171`](https://github.com/sanity-io/ui/commit/7dd317198bd5c7826d5936b7303869c356f8a9af), [`55410de`](https://github.com/sanity-io/ui/commit/55410ded964ac0406c0441e0256d834c7cb5f2f6)]: + - @sanity/ui@4.0.1 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`6069d4e`](https://github.com/sanity-io/ui/commit/6069d4e61a01da3a5124a3c4f68e3c530497912d)]: + - @sanity/ui@4.0.0 + +## 0.3.1 + +### Patch Changes + +- [#2598](https://github.com/sanity-io/ui/pull/2598) [`aafba15`](https://github.com/sanity-io/ui/commit/aafba1501b03b76f6b2337e136f1d5db1ce4d16a) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update sanity monorepo to ^6.9.0 + +- [#2629](https://github.com/sanity-io/ui/pull/2629) [`8109c67`](https://github.com/sanity-io/ui/commit/8109c678c5e04aa2acf51a8db4e85ac7b05a9f92) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update sanity monorepo to ^6.9.1 + +- [#2632](https://github.com/sanity-io/ui/pull/2632) [`5207572`](https://github.com/sanity-io/ui/commit/52075727765ec68b2a718c0b9d9220feaf48df4f) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update dependency styled-components to ^6.5.0 + + styled-components 6.5 tightens polymorphic call-site and `style` prop typing. Adjust `@sanity/ui` for the new checks: narrow `as` at `Box`/`VirtualList` call sites to avoid TS2589, align `Card`'s `$tone`/`$muted` with the values actually passed, and drop now-unnecessary assertions on `motion.create(Card)` wrappers. + +- Updated dependencies [[`df34007`](https://github.com/sanity-io/ui/commit/df34007552a4c195ab2eacce0aff7a7139624c6e), [`c3726c9`](https://github.com/sanity-io/ui/commit/c3726c9ce471cd793ca8d7731c2f433d206031d7), [`5207572`](https://github.com/sanity-io/ui/commit/52075727765ec68b2a718c0b9d9220feaf48df4f)]: + - @sanity/ui@3.5.2 + +## 0.3.0 + +### Minor Changes + +- [#2553](https://github.com/sanity-io/ui/pull/2553) [`e4bff6d`](https://github.com/sanity-io/ui/commit/e4bff6de1c5923107596c87bd54099a3816cd6ed) Thanks [@stipsan](https://github.com/stipsan)! - Widen `@sanity/themer` peer dependencies. `react` now allows `^18 || ^19`, `sanity` allows `^3 || ^4 || ^5 || ^6`, and `styled-components` allows `^5.2 || ^6`. This avoids peer dependency errors for consumers using only `@sanity/themer/legacy` as a migration path off the hosted themer.sanity.build service. These peer dependency ranges do not indicate whether `themerTool` itself will work. + +### Patch Changes + +- [#2543](https://github.com/sanity-io/ui/pull/2543) [`1af3fbd`](https://github.com/sanity-io/ui/commit/1af3fbd608a40718c9eb20dfd71c520550206a60) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update sanity monorepo to ^6.8.0 + +## 0.2.1 + +### Patch Changes + +- [#2497](https://github.com/sanity-io/ui/pull/2497) [`b9ed08d`](https://github.com/sanity-io/ui/commit/b9ed08de271bf5306b6bae48b843742eae461572) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update sanity monorepo to ^6.7.0 + +- Updated dependencies [[`1ecf846`](https://github.com/sanity-io/ui/commit/1ecf846993c94f671856cb09e01caa93a3d7cece)]: + - @sanity/ui@3.5.1 + +## 0.2.0 + +### Minor Changes + +- [#2483](https://github.com/sanity-io/ui/pull/2483) [`818a93c`](https://github.com/sanity-io/ui/commit/818a93cc567cc9d39e5b9e462c3465efcf44ef92) Thanks [@stipsan](https://github.com/stipsan)! - Add a root `buildTheme` export that generates a Studio theme from a handful of colors — `accent` (required), `text`, `background.dark`/`background.light` and `contrast` — by replacing the `@sanity/color` palette that `buildTheme` from `@sanity/ui/theme` otherwise uses. Called with the stock colors it reproduces the stock theme exactly. `buildPalette` exposes the generated palette on its own, and `presets` ships the hosted Themer service presets translated to the new options. The `themerTool` Studio plugin now edits and previews these themes instead of the legacy ones (the `/legacy` subpath is unchanged). + +## 0.1.0 + +### Minor Changes + +- [#2458](https://github.com/sanity-io/ui/pull/2458) [`45bac6a`](https://github.com/sanity-io/ui/commit/45bac6a6d1e79b65006966bf85f18dab9531942f) Thanks [@stipsan](https://github.com/stipsan)! - Introduce `@sanity/themer`, the npm migration path off the hosted Themer service (themer.sanity.build): `@sanity/themer/legacy` is a drop-in replacement for `https://themer.sanity.build/api/hues` URL imports — same `createTheme`/`hues`/`theme` exports, presets and generated colors, plus `buildThemeFromUrl` to migrate an existing URL import in one line. diff --git a/plugins/@sanity/themer/README.md b/plugins/@sanity/themer/README.md new file mode 100644 index 0000000000..8840f12ca2 --- /dev/null +++ b/plugins/@sanity/themer/README.md @@ -0,0 +1,86 @@ +# @sanity/themer + +Generate [Sanity Studio](https://www.sanity.io/studio) themes from a handful of colors. + +```sh +npm install @sanity/themer +``` + +## Usage + +`buildTheme` builds the same type of theme as `buildTheme` from `@sanity/ui/theme` — ready for the `theme` property of a Studio config — but takes a handful of colors per color scheme instead of design tokens. Under the hood it only swaps out the color palette that `@sanity/ui/theme` otherwise fills with [`@sanity/color`](https://www.sanity.io/docs/color), once for each scheme: + +```ts +import {buildTheme} from '@sanity/themer' +import {defineConfig} from 'sanity' + +export const theme = buildTheme({ + light: { + accent: '#f00', + text: '#727892', + background: '#ffffff', + contrast: 85, // 15–100 + }, + dark: { + accent: '#f66', + background: '#0d0e12', + }, +}) + +export default defineConfig({ + theme, + // ...rest of the config +}) +``` + +Everything is optional — both schemes, and every color in them. Whatever is omitted falls back to the stock Studio colors, so `buildTheme({})` matches `buildTheme()` from `@sanity/ui/theme` with no options exactly, and the two schemes are free to differ in every color rather than only their background: + +- `accent` replaces the `blue` scale of the scheme's palette, which Sanity UI uses for primary buttons, focus rings and links. +- `text` replaces the `gray` scale — text, icons, borders and neutral surfaces. When omitted it is derived from `accent`: a mostly desaturated version of it, the way the stock gray carries a hint of the stock blue. +- `background` replaces `black` in the dark scheme and `white` in the light scheme — the background that every other color of the scheme blends onto. +- `contrast` controls how strongly text and borders separate from the accent. The default `85` uses the text color as-is; `100` removes its tint entirely (a high contrast scheme with no mixing of text and accent), and lower values blend more and more of the accent into the text scale, giving text and borders more color. + +A few ground rules keep the generated palettes usable: the accent and text colors cannot be too dark or too light (they would mess with the rest of their scales), the dark background is made darker until it has enough contrast with both of them (it can never be lighter than either), and the light background can never be darker than either. + +`buildPalette` returns the generated `@sanity/color`-shaped palettes (`{light, dark}`) without building a theme from them, and `presets` ships the hosted Themer service presets translated to `buildTheme` options: + +```ts +import {buildTheme, presets} from '@sanity/themer' + +const verdant = presets.find((preset) => preset.slug === 'verdant') +const theme = buildTheme(verdant.options) +``` + +## Studio tool + +`@sanity/themer/tool` adds a themer sidebar to the Studio for these themes. It lists the configured theme, the presets and your own themes, each previewed as a tiny Studio in both color schemes, and picking one applies it live to the whole Studio while you browse around — in the appearance the Studio is set to or, with the split-screen toggle in the sidebar header, twice: the Studio next to the sidebar keeps the appearance it is set to, and a copy in the opposite scheme slides in from off screen on the far side (or on top, on small screens, where the sidebar covers the Studio instead of standing next to it), through React's `ViewTransition` — which is why the tool requires React 19.3. The cards drag into any order (or move from their menu), which sticks between sessions, and a theme's menu copies it as a short code to share — paste a code into the list, or add it through the paste button, to get the theme in another Studio. Your own themes are edited scheme by scheme — a light mode and a dark mode card with accent/text/background pickers and a contrast slider each, the one the Studio is showing marked as active — add one from scratch, duplicate a preset to start from it, or take the colors from an image: its palette is read on device (the image is drawn onto a canvas and its vibrant and muted colors extracted right there, nothing is uploaded), the vibrant color becomes the accent (adjusted until a button label reads on it), the muted one the text color and the light and dark muted colors tint the backgrounds, and a row of variants previews the theme built around each swatch instead — with an "I'm feeling lucky" button that picks an interesting one. Themes can be removed and restored, and a dialog shows the `buildTheme` snippet that makes the applied theme permanent: + +```ts +import {themerTool} from '@sanity/themer/tool' +import {defineConfig} from 'sanity' + +export default defineConfig({ + plugins: [themerTool()], + // ...rest of the config +}) +``` + +If the Studio already uses a `buildTheme` theme, pass the same options so the tool starts editing from them: `themerTool({config: {light: {accent: '#1cb485'}}})`. + +The tool's styles are a stylesheet, `@sanity/themer/bundle.css`, that `@sanity/themer/tool` imports itself — the Studio's bundler picks it up, nothing to add to the config. The sidebar's code loads the first time it's needed, starting as the pointer moves onto the navbar toggle — pressed before the code has arrived, the toggle's color wheel goes round until it has — so the tool adds little to the Studio's startup. The sidebar slides in from the edge (and closing it ends the split preview), its header takes the height of the Studio navbar so the two line up, and with `prefers-reduced-motion` the sidebar and the split preview switch layouts without animating. + +## Migrating from themer.sanity.build + +The npm migration path off the hosted Themer service ([themer.sanity.build](https://themer.sanity.build)) is [`@sanity/themer-legacy`](https://www.npmjs.com/package/@sanity/themer-legacy): the exact same generator, running locally. + +`@sanity/themer/legacy` still works as a deprecated re-export of that package until `@sanity/themer@1.0` removes it; import from `@sanity/themer-legacy` instead. Migrating a hosted URL import looks like this: + +```diff +-import {theme} from 'https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8' ++import {buildThemeFromUrl} from '@sanity/themer-legacy' ++const theme = buildThemeFromUrl('https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8') +``` + +## License + +MIT © Sanity.io — see [LICENSE](https://github.com/sanity-io/plugins/blob/main/LICENSE) diff --git a/plugins/@sanity/themer/package.json b/plugins/@sanity/themer/package.json new file mode 100644 index 0000000000..1a8b427423 --- /dev/null +++ b/plugins/@sanity/themer/package.json @@ -0,0 +1,106 @@ +{ + "name": "@sanity/themer", + "version": "0.8.0", + "description": "Generate Sanity Studio themes, and preview them with a Studio tool.", + "keywords": [ + "sanity", + "studio", + "theme", + "themer", + "ui" + ], + "homepage": "https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/themer#readme", + "bugs": { + "url": "https://github.com/sanity-io/plugins/issues" + }, + "license": "MIT", + "author": "Sanity.io ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/sanity-io/plugins.git", + "directory": "plugins/@sanity/themer" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": [ + "./dist/tool.js", + "*.css" + ], + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./legacy": "./src/legacy/index.ts", + "./tool": "./src/tool/index.ts", + "./bundle.css": { + "types": "./dist/bundle-css.d.ts", + "browser": "./dist/bundle.css", + "style": "./dist/bundle.css", + "node": "./dist/bundle-css.js", + "default": "./dist/bundle-css.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./legacy": "./dist/legacy.js", + "./tool": "./dist/tool.js", + "./bundle.css": { + "types": "./dist/bundle-css.d.ts", + "browser": "./dist/bundle.css", + "style": "./dist/bundle.css", + "node": "./dist/bundle-css.js", + "default": "./dist/bundle-css.js" + }, + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "tsdown", + "prepack": "turbo run build" + }, + "dependencies": { + "@sanity/color": "catalog:", + "@sanity/icons": "catalog:", + "@sanity/themer-legacy": "^0.2.1", + "@sanity/ui": "catalog:", + "@vanilla-extract/dynamic": "catalog:", + "@xstate/react": "catalog:", + "motion": "catalog:", + "react-refractor": "^4.0.0", + "refractor": "^5.0.0", + "scroll-into-view-if-needed": "catalog:", + "xstate": "catalog:" + }, + "devDependencies": { + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", + "@sanity/vanilla-extract-vite-plugin": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vanilla-extract/css": "catalog:", + "jsdom": "catalog:", + "lightningcss": "^1.33.0", + "oxc-transform-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sanity": "catalog:", + "styled-components": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "react": "^19.3.0", + "sanity": "^3 || ^4 || ^5 || ^6" + }, + "peerDependenciesMeta": { + "sanity": { + "optional": true + } + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } +} diff --git a/plugins/@sanity/themer/src/index.test.ts b/plugins/@sanity/themer/src/index.test.ts new file mode 100644 index 0000000000..a4b23ac628 --- /dev/null +++ b/plugins/@sanity/themer/src/index.test.ts @@ -0,0 +1,33 @@ +import {fileURLToPath} from 'node:url' + +import {expect, test} from 'vitest' +import {getPackageExportsManifest} from 'vitest-package-exports' + +test('package exports', {timeout: 30_000}, async () => { + const manifest = await getPackageExportsManifest({ + importMode: 'dist', + cwd: fileURLToPath(import.meta.url), + }) + + expect(manifest.exports).toMatchInlineSnapshot(` + { + ".": { + "buildPalette": "function", + "buildTheme": "function", + "presets": "object", + }, + "./bundle.css": {}, + "./legacy": { + "buildThemeFromUrl": "function", + "createTheme": "function", + "hues": "object", + "parseHuesFromUrl": "function", + "presets": "object", + "theme": "object", + }, + "./tool": { + "themerTool": "function", + }, + } + `) +}) diff --git a/plugins/@sanity/themer/src/index.ts b/plugins/@sanity/themer/src/index.ts new file mode 100644 index 0000000000..1e7de836ee --- /dev/null +++ b/plugins/@sanity/themer/src/index.ts @@ -0,0 +1,8 @@ +export { + buildPalette, + type GeneratedColorPalette, + type GeneratedColorPalettes, +} from './theme/buildPalette' +export {buildTheme} from './theme/buildTheme' +export type {BuildThemeOptions, SchemeThemeOptions} from './theme/options' +export {presets, type ThemePreset} from './theme/presets' diff --git a/plugins/@sanity/themer/src/legacy/index.ts b/plugins/@sanity/themer/src/legacy/index.ts new file mode 100644 index 0000000000..b0aa17f60c --- /dev/null +++ b/plugins/@sanity/themer/src/legacy/index.ts @@ -0,0 +1,96 @@ +// The legacy generator moved to `@sanity/themer-legacy`. Each member is +// re-exported through its own declaration rather than `export {x} from`, so +// the `@deprecated` tags survive into the bundled `.d.ts` and TypeScript +// flags imports of them — plain re-exports carry no TSDoc of their own. +import * as themerLegacy from '@sanity/themer-legacy' + +/** + * @deprecated Import `buildThemeFromUrl` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const buildThemeFromUrl: typeof themerLegacy.buildThemeFromUrl = + themerLegacy.buildThemeFromUrl + +/** + * @deprecated Import `createTheme` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const createTheme: typeof themerLegacy.createTheme = themerLegacy.createTheme + +/** + * @deprecated Import `parseHuesFromUrl` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const parseHuesFromUrl: typeof themerLegacy.parseHuesFromUrl = themerLegacy.parseHuesFromUrl + +/** + * @deprecated Import `hues` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const hues: typeof themerLegacy.hues = themerLegacy.hues + +/** + * @deprecated Import `theme` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const theme: typeof themerLegacy.theme = themerLegacy.theme + +/** + * @deprecated Import `presets` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` re-exports it unchanged for now and will be removed + * in `@sanity/themer@1.0`. + * @public + */ +export const presets: typeof themerLegacy.presets = themerLegacy.presets + +/** + * @deprecated Import `Hue` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type Hue = themerLegacy.Hue + +/** + * @deprecated Import `HueMidPoint` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type HueMidPoint = themerLegacy.HueMidPoint + +/** + * @deprecated Import `Hues` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type Hues = themerLegacy.Hues + +/** + * @deprecated Import `LegacyTheme` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type LegacyTheme = themerLegacy.LegacyTheme + +/** + * @deprecated Import `PartialHues` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type PartialHues = themerLegacy.PartialHues + +/** + * @deprecated Import `ThemePreset` from `@sanity/themer-legacy` instead. + * `@sanity/themer/legacy` will be removed in `@sanity/themer@1.0`. + * @public + */ +export type ThemePreset = themerLegacy.ThemePreset diff --git a/plugins/@sanity/themer/src/legacy/reexport.test.ts b/plugins/@sanity/themer/src/legacy/reexport.test.ts new file mode 100644 index 0000000000..7e4d2c4aac --- /dev/null +++ b/plugins/@sanity/themer/src/legacy/reexport.test.ts @@ -0,0 +1,27 @@ +// oxlint-disable no-deprecated -- the subpath under test is deprecated in favor of the package it re-exports +import * as themerLegacy from '@sanity/themer-legacy' +import {describe, expect, it} from 'vitest' + +import * as legacySubpath from './index' + +describe('@sanity/themer/legacy', () => { + it('re-exports the very same generator API as @sanity/themer-legacy', () => { + expect(legacySubpath.buildThemeFromUrl).toBe(themerLegacy.buildThemeFromUrl) + expect(legacySubpath.createTheme).toBe(themerLegacy.createTheme) + expect(legacySubpath.parseHuesFromUrl).toBe(themerLegacy.parseHuesFromUrl) + expect(legacySubpath.hues).toBe(themerLegacy.hues) + expect(legacySubpath.theme).toBe(themerLegacy.theme) + expect(legacySubpath.presets).toBe(themerLegacy.presets) + }) + + it('exports nothing but the generator API', () => { + expect(Object.keys(legacySubpath).sort()).toEqual([ + 'buildThemeFromUrl', + 'createTheme', + 'hues', + 'parseHuesFromUrl', + 'presets', + 'theme', + ]) + }) +}) diff --git a/plugins/@sanity/themer/src/lib/mix.ts b/plugins/@sanity/themer/src/lib/mix.ts new file mode 100644 index 0000000000..f149632fdf --- /dev/null +++ b/plugins/@sanity/themer/src/lib/mix.ts @@ -0,0 +1,13 @@ +/** + * Matches `#abc` and `#aabbcc` hex colors (the only formats the theme + * generators accept), same as the hosted Themer service did. + * + * The `mix` function this module was named for moved to `@sanity/themer-legacy` + * along with the legacy generator; `isColor` stays here for `theme/options.ts` + * and `tool/storage.ts`. + * + * @internal + */ +export function isColor(input: string): boolean { + return /^#(?:[0-9a-f]{3}){1,2}$/i.test(input) +} diff --git a/plugins/@sanity/themer/src/theme/buildPalette.ts b/plugins/@sanity/themer/src/theme/buildPalette.ts new file mode 100644 index 0000000000..f3f55f0d87 --- /dev/null +++ b/plugins/@sanity/themer/src/theme/buildPalette.ts @@ -0,0 +1,267 @@ +import { + color, + COLOR_TINTS, + type ColorHueKey, + type ColorTintKey, + type ColorTints, +} from '@sanity/color' +import {type ThemeColorSchemeKey} from '@sanity/ui/theme' + +import {hexToHsl, type Hsl, hslToHex, mixHue, relativeLuminance} from './hsl' +import { + type BuildThemeOptions, + DEFAULT_ACCENT, + DEFAULT_CONTRAST, + DEFAULT_TEXT, + MAXIMUM_CONTRAST, + MINIMUM_CONTRAST, + type ResolvedSchemeOptions, + resolveThemeOptions, +} from './options' + +/** + * The lightness band the accent and text colors are clamped into — anchors + * outside it would squeeze most of their scale's tints into an + * indistinguishable sliver, messing with the rest of the scale. + */ +const MINIMUM_ANCHOR_LIGHTNESS = 0.25 +const MAXIMUM_ANCHOR_LIGHTNESS = 0.75 + +/** + * The contrast ratio the dark background must reach against the 400 tints of + * the text and accent scales — the tints Sanity UI paints muted text and + * solid buttons with on dark backgrounds. The stock palette sits at ~6.8. + */ +const MINIMUM_DARK_BACKGROUND_CONTRAST = 4.5 + +/** How much lightness each step of a background adjustment removes or adds */ +const BACKGROUND_LIGHTNESS_STEP = 0.005 + +/** + * A color palette generated by {@link buildPalette}: the `palette` config of + * `buildTheme` from `@sanity/ui/theme`, with every color as a hex string. + * + * @public + */ +export type GeneratedColorPalette = {black: string; white: string} & Record< + ColorHueKey, + Record +> + +/** + * The palettes generated by {@link buildPalette} — one per color scheme. + * + * @public + */ +export type GeneratedColorPalettes = Record + +/** + * Generates a Sanity UI color palette for each color scheme from the theme + * options: in each palette the scheme's accent color replaces the `blue` + * scale, its text color the `gray` scale, and the backgrounds replace `black` + * and `white`. All other hues keep their stock `@sanity/color` values. Called + * with the stock colors, both palettes come back as the stock palette + * byte-for-byte. + * + * ```ts + * import {buildPalette} from '@sanity/themer' + * + * const {light, dark} = buildPalette({light: {accent: '#556bfc'}}) + * ``` + * + * @public + */ +export function buildPalette(options: BuildThemeOptions = {}): GeneratedColorPalettes { + const resolved = resolveThemeOptions(options) + + return { + dark: buildSchemePalette('dark', resolved.dark, resolved.light.background), + light: buildSchemePalette('light', resolved.light, resolved.dark.background), + } +} + +/** + * Generates the palette of one scheme. The scheme's own background takes the + * slot the scheme paints with (`black` for dark, `white` for light), while + * the other slot — which a scheme only touches for a few inverted details — + * takes the other scheme's background, so the theme stays one palette. + */ +function buildSchemePalette( + key: ThemeColorSchemeKey, + scheme: ResolvedSchemeOptions, + otherBackground: string, +): GeneratedColorPalette { + const accentAnchor = clampAnchorLightness(hexToHsl(scheme.accent)) + const textAnchor = clampAnchorLightness(hexToHsl(scheme.text)) + const grayAnchor = applyContrast(textAnchor, accentAnchor, scheme.contrast) + + const blueTints = remapTints(color.blue, hexToHsl(DEFAULT_ACCENT), accentAnchor) + const grayTints = remapTints(color.gray, hexToHsl(DEFAULT_TEXT), grayAnchor) + + const anchorLuminances = [ + relativeLuminance(hslToHex(grayAnchor)), + relativeLuminance(hslToHex(accentAnchor)), + ] + + const darkBackground = key === 'dark' ? scheme.background : otherBackground + const lightBackground = key === 'light' ? scheme.background : otherBackground + + return { + black: darkenBackground(darkBackground, { + anchorLuminances, + tints: [grayTints['400'], blueTints['400']], + }), + white: lightenBackground(lightBackground, anchorLuminances), + gray: grayTints, + blue: blueTints, + purple: tintHexes(color.purple), + magenta: tintHexes(color.magenta), + red: tintHexes(color.red), + orange: tintHexes(color.orange), + yellow: tintHexes(color.yellow), + green: tintHexes(color.green), + cyan: tintHexes(color.cyan), + } +} + +/** + * Mixes the accent color into the text color according to the contrast: 85 + * applies the text color as-is, 100 removes its tint entirely, and lower + * values rotate and saturate it towards the accent. + */ +function applyContrast(text: Hsl, accent: Hsl, contrast: number): Hsl { + if (contrast === DEFAULT_CONTRAST) { + return text + } + + if (contrast > DEFAULT_CONTRAST) { + const amount = (contrast - DEFAULT_CONTRAST) / (MAXIMUM_CONTRAST - DEFAULT_CONTRAST) + + return {h: text.h, s: text.s * (1 - amount), l: text.l} + } + + const amount = (DEFAULT_CONTRAST - contrast) / (DEFAULT_CONTRAST - MINIMUM_CONTRAST) + + return { + h: mixHue(text.h, accent.h, amount), + s: text.s + (accent.s - text.s) * amount, + l: text.l, + } +} + +/** + * Rebuilds a stock tint scale around a new anchor color, where `from` is the + * scale's stock 500 tint: hues rotate along, saturation scales + * proportionally, and lightness is remapped piecewise so the anchor takes the + * new lightness while the scale keeps spanning light to dark. When the anchor + * matches the stock one, every tint comes back unchanged. + */ +function remapTints(tints: ColorTints, from: Hsl, to: Hsl): Record { + const rotation = to.h - from.h + const result: Partial> = {} + + for (const tint of COLOR_TINTS) { + const value = hexToHsl(tints[tint].hex) + + result[tint] = hslToHex({ + h: value.h + rotation, + s: remapSaturation(value.s, from.s, to.s), + l: remapLightness(value.l, from.l, to.l), + }) + } + + // oxlint-disable-next-line no-unsafe-type-assertion -- the loop assigns every tint key + return result as Record +} + +function remapSaturation(value: number, from: number, to: number): number { + if (from === to) { + return value + } + + return Math.min(1, value * (to / from)) +} + +function remapLightness(value: number, from: number, to: number): number { + if (from === to) { + return value + } + + if (value <= from) { + return value * (to / from) + } + + return to + ((value - from) * (1 - to)) / (1 - from) +} + +function clampAnchorLightness(anchor: Hsl): Hsl { + const l = Math.min(MAXIMUM_ANCHOR_LIGHTNESS, Math.max(MINIMUM_ANCHOR_LIGHTNESS, anchor.l)) + + return l === anchor.l ? anchor : {...anchor, l} +} + +/** + * Enforces the dark background rules: it can never be lighter than the text + * or accent colors, and it must reach enough contrast with the 400 tints of + * their scales — it is made darker until it does (or bottoms out at black). + */ +function darkenBackground( + hex: string, + rules: {anchorLuminances: number[]; tints: string[]}, +): string { + const maximumLuminance = Math.min(...rules.anchorLuminances) + const tintLuminances = rules.tints.map((tint) => relativeLuminance(tint)) + + const isDarkEnough = (background: string): boolean => { + const luminance = relativeLuminance(background) + + return ( + luminance < maximumLuminance && + tintLuminances.every( + (tintLuminance) => + (tintLuminance + 0.05) / (luminance + 0.05) >= MINIMUM_DARK_BACKGROUND_CONTRAST, + ) + ) + } + + const {h, s, l} = hexToHsl(hex) + let lightness = l + let current = hex + + while (!isDarkEnough(current) && lightness > 0) { + lightness = Math.max(0, lightness - BACKGROUND_LIGHTNESS_STEP) + current = hslToHex({h, s, l: lightness}) + } + + return current +} + +/** + * Enforces the light background rule: it can never be darker than the text + * or accent colors — it is made lighter until it is (or tops out at white). + */ +function lightenBackground(hex: string, anchorLuminances: number[]): string { + const minimumLuminance = Math.max(...anchorLuminances) + + const {h, s, l} = hexToHsl(hex) + let lightness = l + let current = hex + + while (relativeLuminance(current) <= minimumLuminance && lightness < 1) { + lightness = Math.min(1, lightness + BACKGROUND_LIGHTNESS_STEP) + current = hslToHex({h, s, l: lightness}) + } + + return current +} + +function tintHexes(tints: ColorTints): Record { + const result: Partial> = {} + + for (const tint of COLOR_TINTS) { + result[tint] = tints[tint].hex + } + + // oxlint-disable-next-line no-unsafe-type-assertion -- the loop assigns every tint key + return result as Record +} diff --git a/plugins/@sanity/themer/src/theme/buildTheme.ts b/plugins/@sanity/themer/src/theme/buildTheme.ts new file mode 100644 index 0000000000..29a72076d0 --- /dev/null +++ b/plugins/@sanity/themer/src/theme/buildTheme.ts @@ -0,0 +1,72 @@ +import { + buildTheme as buildUITheme, + type RootTheme, + type ThemeColorSchemes_v2, + v0_v2, + v2_v0, +} from '@sanity/ui/theme' + +import {buildPalette} from './buildPalette' +import type {BuildThemeOptions} from './options' + +/** + * Generates a Studio theme from a handful of colors per color scheme, by + * replacing the color palette that the `buildTheme` from `@sanity/ui/theme` + * otherwise fills with `@sanity/color`: in each scheme the accent color + * replaces the `blue` scale (primary buttons, focus rings, links), the + * optional text color the `gray` scale (text, icons, borders), and the + * optional background replaces `black` (dark) or `white` (light). The result + * is the same type of theme that `@sanity/ui/theme` builds, ready for the + * `theme` property of a Studio config. + * + * ```ts + * import {buildTheme} from '@sanity/themer' + * import {defineConfig} from 'sanity' + * + * export const theme = buildTheme({ + * light: { + * accent: '#556bfc', + * text: '#727892', // optional — derived from `accent` when omitted + * background: '#ffffff', // optional + * contrast: 85, // optional, 15–100 + * }, + * dark: { + * accent: '#556bfc', + * background: '#0d0e12', + * }, + * }) + * + * export default defineConfig({ + * theme, + * // ...rest of the config + * }) + * ``` + * + * Every option is optional and falls back to the stock Studio colors: called + * with the stock colors — like the example — or with no options at all, it + * returns the exact same colors as `buildTheme()` from `@sanity/ui/theme`. + * + * @public + */ +export function buildTheme(options: BuildThemeOptions = {}): RootTheme { + const palettes = buildPalette(options) + + // Sanity UI renders both schemes of a theme from one palette, so each + // scheme gets a theme of its own palette, and the two are composed into one + // theme that takes its light scheme from the one and its dark scheme from + // the other. The scheme getters keep Sanity UI's lazy rendering: a scheme is + // only computed once something reads it. + const light = v0_v2(buildUITheme({palette: palettes.light})) + const dark = v0_v2(buildUITheme({palette: palettes.dark})) + + const color: ThemeColorSchemes_v2 = { + get light() { + return light.color.light + }, + get dark() { + return dark.color.dark + }, + } + + return v2_v0({...light, color}) +} diff --git a/plugins/@sanity/themer/src/theme/hsl.ts b/plugins/@sanity/themer/src/theme/hsl.ts new file mode 100644 index 0000000000..639dd67ba7 --- /dev/null +++ b/plugins/@sanity/themer/src/theme/hsl.ts @@ -0,0 +1,137 @@ +/** + * Color math for the palette generator, kept at full float precision: unlike + * the HSL helpers in `@sanity/ui/theme`, nothing is rounded between the two + * conversions, so `hslToHex(hexToHsl(hex))` returns `hex` unchanged for every + * color. `buildTheme` relies on that to reproduce the stock `@sanity/color` + * palette byte-for-byte when it is called with the stock anchor colors. + */ + +/** + * A color in HSL space: `h` in degrees within `[0, 360)`, `s` and `l` as + * fractions within `[0, 1]`. + * + * @internal + */ +export interface Hsl { + h: number + s: number + l: number +} + +/** @internal */ +export function hexToHsl(hex: string): Hsl { + const [r, g, b] = parseHex(hex) + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const delta = max - min + + const l = (max + min) / 2 + + if (delta === 0) { + return {h: 0, s: 0, l} + } + + const s = delta / (1 - Math.abs(2 * l - 1)) + + let h: number + + if (max === r) { + h = ((g - b) / delta) % 6 + } else if (max === g) { + h = (b - r) / delta + 2 + } else { + h = (r - g) / delta + 4 + } + + return {h: normalizeHue(h * 60), s, l} +} + +/** @internal */ +export function hslToHex(hsl: Hsl): string { + const h = normalizeHue(hsl.h) + const s = clamp01(hsl.s) + const l = clamp01(hsl.l) + + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + + let r = 0 + let g = 0 + let b = 0 + + if (h < 60) { + r = c + g = x + } else if (h < 120) { + r = x + g = c + } else if (h < 180) { + g = c + b = x + } else if (h < 240) { + g = x + b = c + } else if (h < 300) { + r = x + b = c + } else { + r = c + b = x + } + + return `#${channelToHex(r + m)}${channelToHex(g + m)}${channelToHex(b + m)}` +} + +/** + * Rotates `from` towards `to` along the shortest arc of the hue circle. + * + * @internal + */ +export function mixHue(from: number, to: number, amount: number): number { + const delta = ((((to - from) % 360) + 540) % 360) - 180 + + return normalizeHue(from + delta * amount) +} + +/** + * The WCAG relative luminance of a hex color, between 0 (black) and 1 (white). + * + * @internal + */ +export function relativeLuminance(hex: string): number { + const [r, g, b] = parseHex(hex) + + return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b) +} + +function linearChannel(value: number): number { + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 +} + +/** Parses `#abc` and `#aabbcc` hex colors into channel fractions of 1 */ +function parseHex(hex: string): [number, number, number] { + const expanded = + hex.length === 4 ? `${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex.slice(1) + + return [ + parseInt(expanded.slice(0, 2), 16) / 255, + parseInt(expanded.slice(2, 4), 16) / 255, + parseInt(expanded.slice(4, 6), 16) / 255, + ] +} + +function channelToHex(value: number): string { + return Math.round(clamp01(value) * 255) + .toString(16) + .padStart(2, '0') +} + +function normalizeHue(hue: number): number { + return ((hue % 360) + 360) % 360 +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)) +} diff --git a/plugins/@sanity/themer/src/theme/options.ts b/plugins/@sanity/themer/src/theme/options.ts new file mode 100644 index 0000000000..ee74ebfe32 --- /dev/null +++ b/plugins/@sanity/themer/src/theme/options.ts @@ -0,0 +1,206 @@ +import {black, blue, gray, white} from '@sanity/color' +import {type ThemeColorSchemeKey} from '@sanity/ui/theme' + +import {isColor} from '../lib/mix' +import {hexToHsl, hslToHex} from './hsl' + +/** + * The colors of one color scheme, for {@link BuildThemeOptions}. Every color + * is a hex color (`#rgb` or `#rrggbb`), and everything is optional: omitted + * options fall back to the stock Studio colors, so an empty object reproduces + * the stock scheme exactly. + * + * @public + */ +export interface SchemeThemeOptions { + /** + * The accent color — the brand color of the scheme. Replaces the `blue` + * scale of the palette, which Sanity UI uses for primary buttons, focus + * rings and links. + * + * The stock accent is `#556bfc` (`blue/500` of `@sanity/color`). + */ + accent?: string + + /** + * The background of the scheme — every other color of the scheme is + * blended onto it. The dark scheme's background replaces `black` in the + * palette and the light scheme's replaces `white`. + * + * It is kept far enough from the text and accent colors to stay usable: + * a dark background is made darker until it has enough contrast with them + * (it can never be lighter than either), and a light background can never + * be darker than either. + * + * The stock backgrounds are `#0d0e12` (dark) and `#ffffff` (light). + */ + background?: string + + /** + * How strongly text and borders separate from the accent color, between 15 + * and 100. The default is 85, which uses the text color as-is. Above 85 the + * text scale loses its tint until it is fully neutral at 100 — a high + * contrast scheme with no mixing of the text and accent colors. Below 85 + * more and more of the accent color mixes into the text scale, giving text + * and borders more color, until the scale fully adopts the accent hue and + * saturation at 15. + */ + contrast?: number + + /** + * The text color. Replaces the `gray` scale of the palette, which Sanity UI + * uses for text, icons, borders and neutral surfaces. When omitted, it is + * derived from the accent color: a mostly desaturated version of it, the + * way the stock `gray` scale carries a hint of the stock blue. + * + * The stock text color is `#727892` (`gray/500` of `@sanity/color`). + */ + text?: string +} + +/** + * Options for {@link buildTheme} and {@link buildPalette}: the colors of the + * dark and the light scheme, each with its own accent, text, background and + * contrast — so the two schemes can differ in more than their background. + * Both schemes are optional, and so is every color: whatever is omitted + * falls back to the stock Studio colors, so `buildTheme({})` matches + * `buildTheme()` from `@sanity/ui/theme` exactly. + * + * @public + */ +export interface BuildThemeOptions { + /** The colors of the dark scheme */ + dark?: SchemeThemeOptions + /** The colors of the light scheme */ + light?: SchemeThemeOptions +} + +/** + * {@link SchemeThemeOptions} with every option filled in: colors normalized + * to lowercase `#rrggbb`, the text color derived from the accent when it was + * omitted, and the contrast clamped into its valid range. + * + * @internal + */ +export interface ResolvedSchemeOptions { + accent: string + background: string + contrast: number + text: string +} + +/** {@link BuildThemeOptions} with both schemes resolved @internal */ +export type ResolvedThemeOptions = Record + +/** The color schemes, in the order the tool presents them @internal */ +export const SCHEMES: ThemeColorSchemeKey[] = ['light', 'dark'] + +/** The stock accent color — `blue/500` of `@sanity/color` @internal */ +export const DEFAULT_ACCENT: string = blue[500].hex + +/** The stock text color — `gray/500` of `@sanity/color` @internal */ +export const DEFAULT_TEXT: string = gray[500].hex + +/** The stock backgrounds — `black` and `white` of `@sanity/color` @internal */ +export const DEFAULT_BACKGROUND: Record = { + dark: black.hex, + light: white.hex, +} + +/** The contrast that applies the text color as-is @internal */ +export const DEFAULT_CONTRAST = 85 + +/** @internal */ +export const MINIMUM_CONTRAST = 15 + +/** @internal */ +export const MAXIMUM_CONTRAST = 100 + +/** The keys of the options shape before they were grouped by scheme */ +const FLAT_OPTION_KEYS = ['accent', 'text', 'background', 'contrast'] + +/** @internal */ +export function resolveThemeOptions(options: BuildThemeOptions = {}): ResolvedThemeOptions { + if (FLAT_OPTION_KEYS.some((key) => key in options)) { + throw new TypeError( + 'The `buildTheme` options are grouped by color scheme: pass `{light: {accent, text, background, contrast}, dark: {…}}` instead of one set of colors for both', + ) + } + + return { + dark: resolveSchemeOptions(options.dark ?? {}, 'dark'), + light: resolveSchemeOptions(options.light ?? {}, 'light'), + } +} + +function resolveSchemeOptions( + options: SchemeThemeOptions, + scheme: ThemeColorSchemeKey, +): ResolvedSchemeOptions { + const accent = + options.accent === undefined + ? DEFAULT_ACCENT + : normalizeColor(options.accent, `${scheme}.accent`) + + return { + accent, + background: + options.background === undefined + ? DEFAULT_BACKGROUND[scheme] + : normalizeColor(options.background, `${scheme}.background`), + contrast: normalizeContrast(options.contrast, scheme), + text: + options.text === undefined + ? deriveTextColor(accent) + : normalizeColor(options.text, `${scheme}.text`), + } +} + +/** + * Derives the text color from an accent color, keeping the relationship the + * stock palette has between `gray/500` and `blue/500`: the text color takes + * the accent's hue (offset the way the stock gray is offset from the stock + * blue), carries about an eighth of its saturation, and keeps the stock text + * lightness. Deriving from the stock accent returns the stock text color. + * + * @internal + */ +export function deriveTextColor(accent: string): string { + const accentHsl = hexToHsl(normalizeColor(accent, 'accent')) + const defaultAccent = hexToHsl(DEFAULT_ACCENT) + const defaultText = hexToHsl(DEFAULT_TEXT) + + return hslToHex({ + h: defaultText.h + (accentHsl.h - defaultAccent.h), + s: Math.min(1, defaultText.s * (accentHsl.s / defaultAccent.s)), + l: defaultText.l, + }) +} + +function normalizeColor(value: string, name: string): string { + if (typeof value !== 'string' || !isColor(value)) { + throw new TypeError( + `Invalid \`${name}\` color: ${JSON.stringify(value)} — expected a hex color like #556bfc`, + ) + } + + const lower = value.toLowerCase() + + return lower.length === 4 + ? `#${lower[1]}${lower[1]}${lower[2]}${lower[2]}${lower[3]}${lower[3]}` + : lower +} + +function normalizeContrast(value: number | undefined, scheme: ThemeColorSchemeKey): number { + if (value === undefined) { + return DEFAULT_CONTRAST + } + + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError( + `Invalid \`${scheme}.contrast\`: ${JSON.stringify(value)} — expected a number between ${MINIMUM_CONTRAST} and ${MAXIMUM_CONTRAST}`, + ) + } + + return Math.min(MAXIMUM_CONTRAST, Math.max(MINIMUM_CONTRAST, value)) +} diff --git a/plugins/@sanity/themer/src/theme/presets.ts b/plugins/@sanity/themer/src/theme/presets.ts new file mode 100644 index 0000000000..f862f66f7f --- /dev/null +++ b/plugins/@sanity/themer/src/theme/presets.ts @@ -0,0 +1,83 @@ +import type {BuildThemeOptions} from './options' + +/** + * A preset theme: a named set of {@link BuildThemeOptions} ready to pass to + * `buildTheme`. + * + * @public + */ +export interface ThemePreset { + slug: string + title: string + /** The preset's theme options, ready to pass to `buildTheme` */ + options: BuildThemeOptions +} + +/** + * The hosted Themer presets had one accent and one text color for both + * schemes, and a background per scheme + */ +function scheme(accent: string, text: string, backgrounds: {dark: string; light: string}) { + return { + dark: {accent, text, background: backgrounds.dark}, + light: {accent, text, background: backgrounds.light}, + } satisfies BuildThemeOptions +} + +/** + * Preset themes for `buildTheme`, carried over from the hosted Themer service + * (themer.sanity.build) presets: each one maps the legacy preset's primary + * hue to `accent`, its default hue to `text` and its lightest/darkest colors + * to the backgrounds of the light and dark scheme. + * + * ```ts + * import {buildTheme, presets} from '@sanity/themer' + * + * const verdant = presets.find((preset) => preset.slug === 'verdant') + * const theme = buildTheme(verdant.options) + * ``` + * + * @public + */ +export const presets: ThemePreset[] = [ + { + slug: 'studio', + title: 'Studio', + options: {}, + }, + { + slug: 'dew', + title: 'Dew', + options: scheme('#d1a308', '#5e63b4', {dark: '#0d0d15', light: '#fcfcfd'}), + }, + { + slug: 'pink-synth', + title: 'Pink Synth', + options: scheme('#ec4899', '#8b6584', {dark: '#171721', light: '#f7f2f5'}), + }, + { + slug: 'pixel-art', + title: 'Pixel Art', + options: scheme('#f10784', '#57619c', {dark: '#0d0e15', light: '#fcfcfd'}), + }, + { + slug: 'retro-colonial', + title: 'Retro Colonial', + options: scheme('#fa7a78', '#8bb9b5', {dark: '#0d1515', light: '#fcfdfd'}), + }, + { + slug: 'rosabel', + title: 'Rosabel', + options: scheme('#ed2555', '#9d8966', {dark: '#15120d', light: '#fdfdfc'}), + }, + { + slug: 'stereofidelic', + title: 'Stereofidelic', + options: scheme('#f13009', '#678e9a', {dark: '#0e1315', light: '#fcfdfd'}), + }, + { + slug: 'verdant', + title: 'Verdant', + options: scheme('#1cb485', '#5c9199', {dark: '#0d1415', light: '#fcfdfd'}), + }, +] diff --git a/plugins/@sanity/themer/src/theme/theme.test.ts b/plugins/@sanity/themer/src/theme/theme.test.ts new file mode 100644 index 0000000000..93552aa153 --- /dev/null +++ b/plugins/@sanity/themer/src/theme/theme.test.ts @@ -0,0 +1,274 @@ +import {color, COLOR_HUES, COLOR_TINTS} from '@sanity/color' +import {buildTheme as buildUITheme} from '@sanity/ui/theme' +import {describe, expect, it} from 'vitest' + +import {buildPalette} from './buildPalette' +import {buildTheme} from './buildTheme' +import {hexToHsl, hslToHex, relativeLuminance} from './hsl' +import {type BuildThemeOptions, deriveTextColor, resolveThemeOptions} from './options' +import {presets} from './presets' + +/** The stock options, spelled out the way a Studio config would */ +const STOCK_OPTIONS: BuildThemeOptions = { + light: {accent: '#556bfc', text: '#727892', background: '#ffffff', contrast: 85}, + dark: {accent: '#556bfc', text: '#727892', background: '#0d0e12', contrast: 85}, +} + +/** The same colors for both schemes, the way the flat options used to work */ +function both(scheme: {accent?: string; text?: string; contrast?: number}): BuildThemeOptions { + return {light: {...scheme}, dark: {...scheme}} +} + +function forceTree(value: unknown): unknown { + // The themes are built out of lazy self-replacing getters — serializing + // forces every one of them into a comparable plain tree + return JSON.parse(JSON.stringify(value)) +} + +describe('hexToHsl/hslToHex', () => { + it('round-trips every color of the stock palette', () => { + const hexes = [color.black.hex, color.white.hex] + + for (const hue of COLOR_HUES) { + for (const tint of COLOR_TINTS) { + hexes.push(color[hue][tint].hex) + } + } + + for (const hex of hexes) { + expect(hslToHex(hexToHsl(hex)), hex).toBe(hex) + } + }) + + it('round-trips arbitrary colors', () => { + // A deterministic sweep across the RGB cube + for (let i = 0; i < 0xffffff; i += 3671) { + const hex = `#${i.toString(16).padStart(6, '0')}` + + expect(hslToHex(hexToHsl(hex)), hex).toBe(hex) + } + }) +}) + +describe('resolveThemeOptions', () => { + it('fills in the stock colors for whatever is omitted', () => { + expect(resolveThemeOptions({})).toEqual(STOCK_OPTIONS) + expect(resolveThemeOptions()).toEqual(STOCK_OPTIONS) + expect(resolveThemeOptions({light: {accent: '#E11D48'}}).light).toEqual({ + accent: '#e11d48', + text: deriveTextColor('#e11d48'), + background: '#ffffff', + contrast: 85, + }) + expect(resolveThemeOptions({light: {accent: '#e11d48'}}).dark).toEqual(STOCK_OPTIONS.dark) + }) + + it('points out the options shape from before they were grouped by scheme', () => { + // oxlint-disable-next-line no-unsafe-type-assertion -- the old shape on purpose + expect(() => resolveThemeOptions({accent: '#556bfc'} as BuildThemeOptions)).toThrow( + /grouped by color scheme/, + ) + }) +}) + +describe('buildPalette', () => { + it('reproduces the stock palette in both schemes from the stock options', () => { + const palettes = buildPalette(STOCK_OPTIONS) + + for (const palette of [palettes.light, palettes.dark]) { + expect(palette.black).toBe(color.black.hex) + expect(palette.white).toBe(color.white.hex) + + for (const hue of COLOR_HUES) { + for (const tint of COLOR_TINTS) { + expect(palette[hue][tint], `${hue}/${tint}`).toBe(color[hue][tint].hex) + } + } + } + }) + + it('reproduces the stock palette from no options at all', () => { + expect(buildPalette()).toEqual(buildPalette(STOCK_OPTIONS)) + expect(buildPalette({})).toEqual(buildPalette(STOCK_OPTIONS)) + expect(buildPalette({light: {}, dark: {contrast: 85}})).toEqual(buildPalette(STOCK_OPTIONS)) + }) + + it('places the accent at blue/500 and the text at gray/500 of each scheme', () => { + const {light, dark} = buildPalette({ + light: {accent: '#e11d48', text: '#6b7280'}, + dark: {accent: '#15803d', text: '#57619c'}, + }) + + expect(light.blue['500']).toBe('#e11d48') + expect(light.gray['500']).toBe('#6b7280') + expect(dark.blue['500']).toBe('#15803d') + expect(dark.gray['500']).toBe('#57619c') + }) + + it('keeps the schemes apart', () => { + const {light, dark} = buildPalette({light: {accent: '#e11d48'}}) + + expect(light.blue['500']).toBe('#e11d48') + expect(dark.blue['500']).toBe(color.blue[500].hex) + }) + + it('derives the text color from the accent when omitted', () => { + const derived = buildPalette(both({accent: '#e11d48'})) + const explicit = buildPalette(both({accent: '#e11d48', text: deriveTextColor('#e11d48')})) + + expect(derived).toEqual(explicit) + }) + + it('keeps the other hues at their stock values', () => { + const {light} = buildPalette({light: {accent: '#e11d48', text: '#6b7280'}}) + + for (const hue of ['purple', 'magenta', 'red', 'orange', 'yellow', 'green', 'cyan'] as const) { + for (const tint of COLOR_TINTS) { + expect(light[hue][tint], `${hue}/${tint}`).toBe(color[hue][tint].hex) + } + } + }) + + it('accepts short hex colors and uppercase hex colors', () => { + expect(buildPalette({light: {accent: '#F00'}}).light.blue['500']).toBe('#ff0000') + expect(buildPalette({light: {accent: '#FF0000'}}).light.blue['500']).toBe('#ff0000') + }) + + it('rejects invalid colors and contrasts', () => { + expect(() => buildPalette({light: {accent: 'red'}})).toThrow(TypeError) + expect(() => buildPalette({dark: {text: 'nope'}})).toThrow(TypeError) + expect(() => buildPalette({dark: {background: 'dark'}})).toThrow(TypeError) + expect(() => buildPalette({light: {contrast: Number.NaN}})).toThrow(TypeError) + }) + + it('clamps the accent and text lightness so they cannot mess with the scale', () => { + const {light} = buildPalette({light: {accent: '#eeeeff', text: '#f4f4f6'}}) + + expect(hexToHsl(light.blue['500']).l).toBeCloseTo(0.75, 2) + expect(hexToHsl(light.gray['500']).l).toBeCloseTo(0.75, 2) + + const {dark} = buildPalette({dark: {accent: '#000411', text: '#0a0a10'}}) + + expect(hexToHsl(dark.blue['500']).l).toBeCloseTo(0.25, 2) + expect(hexToHsl(dark.gray['500']).l).toBeCloseTo(0.25, 2) + }) + + it('keeps every scale in light-to-dark tint order', () => { + const {light} = buildPalette({light: {accent: '#15803d', text: '#57619c', contrast: 40}}) + + for (const hue of ['gray', 'blue'] as const) { + for (let index = 1; index < COLOR_TINTS.length; index++) { + const lighter = hexToHsl(light[hue][COLOR_TINTS[index - 1]]).l + const darker = hexToHsl(light[hue][COLOR_TINTS[index]]).l + + expect(lighter, `${hue}/${COLOR_TINTS[index]}`).toBeGreaterThan(darker) + } + } + }) + + it('darkens a dark background that gets too close to the text and accent', () => { + const {dark} = buildPalette({dark: {background: '#888888'}}) + const background = relativeLuminance(dark.black) + + expect(background).toBeLessThan(relativeLuminance('#888888')) + expect(background).toBeLessThan(relativeLuminance('#727892')) + expect(background).toBeLessThan(relativeLuminance('#556bfc')) + + for (const tint of [dark.gray['400'], dark.blue['400']]) { + expect((relativeLuminance(tint) + 0.05) / (background + 0.05)).toBeGreaterThanOrEqual(4.5) + } + }) + + it('never lets the light background get darker than the text and accent', () => { + const {light} = buildPalette({light: {background: '#222222'}}) + const background = relativeLuminance(light.white) + + expect(background).toBeGreaterThan(relativeLuminance('#727892')) + expect(background).toBeGreaterThan(relativeLuminance('#556bfc')) + }) + + it('lends each scheme the background of the other for its inverted details', () => { + const {light, dark} = buildPalette({ + light: {background: '#fcfdfd'}, + dark: {background: '#0d1415'}, + }) + + expect(light.white).toBe('#fcfdfd') + expect(light.black).toBe('#0d1415') + expect(dark.black).toBe('#0d1415') + expect(dark.white).toBe('#fcfdfd') + }) + + it('keeps the backgrounds of every preset untouched', () => { + for (const preset of presets) { + const resolved = resolveThemeOptions(preset.options) + const {light, dark} = buildPalette(preset.options) + + expect(dark.black, `${preset.slug} dark`).toBe(resolved.dark.background) + expect(light.white, `${preset.slug} light`).toBe(resolved.light.background) + } + }) +}) + +describe('contrast', () => { + it('removes every trace of the accent from the text scale at 100', () => { + const {light} = buildPalette({light: {contrast: 100}}) + + for (const tint of COLOR_TINTS) { + const hex = light.gray[tint] + + expect(hex.slice(1, 3), `gray/${tint}`).toBe(hex.slice(3, 5)) + expect(hex.slice(3, 5), `gray/${tint}`).toBe(hex.slice(5, 7)) + } + }) + + it('makes the text scale adopt the accent hue and saturation at 15', () => { + const {light} = buildPalette({light: {accent: '#e11d48', contrast: 15}}) + const accent = hexToHsl(light.blue['500']) + const text = hexToHsl(light.gray['500']) + + expect(text.h).toBeCloseTo(accent.h, 0) + expect(text.s).toBeCloseTo(accent.s, 1) + // The text keeps its own lightness so it stays legible + expect(text.l).toBeCloseTo(hexToHsl(deriveTextColor('#e11d48')).l, 2) + }) + + it('blends more of the accent into the text scale the lower it gets', () => { + const saturationAt = (contrast: number) => + hexToHsl(buildPalette({light: {contrast}}).light.gray['500']).s + + expect(saturationAt(100)).toBeLessThan(saturationAt(85)) + expect(saturationAt(85)).toBeLessThan(saturationAt(50)) + expect(saturationAt(50)).toBeLessThan(saturationAt(15)) + }) + + it('clamps the contrast into its 15–100 range', () => { + expect(buildPalette({dark: {contrast: -20}})).toEqual(buildPalette({dark: {contrast: 15}})) + expect(buildPalette({dark: {contrast: 250}})).toEqual(buildPalette({dark: {contrast: 100}})) + }) +}) + +describe('buildTheme', () => { + it('matches buildTheme() from @sanity/ui/theme for the stock options', () => { + const expected = buildUITheme() + + for (const actual of [buildTheme(STOCK_OPTIONS), buildTheme({}), buildTheme()]) { + // oxlint-disable-next-line no-deprecated -- the v0 colors are compared on purpose, they are what a Studio still consumes + expect(forceTree(actual.color)).toEqual(forceTree(expected.color)) + expect(forceTree(actual.v2?.color)).toEqual(forceTree(expected.v2?.color)) + } + }) + + it('takes each scheme from the palette of that scheme', () => { + const theme = buildTheme({light: {accent: '#e11d48'}, dark: {accent: '#15803d'}}) + const light = buildUITheme({palette: buildPalette({light: {accent: '#e11d48'}}).light}) + const dark = buildUITheme({palette: buildPalette({dark: {accent: '#15803d'}}).dark}) + + expect(forceTree(theme.v2?.color.light)).toEqual(forceTree(light.v2?.color.light)) + expect(forceTree(theme.v2?.color.dark)).toEqual(forceTree(dark.v2?.color.dark)) + expect(theme.v2?.color.light.default.button.default.primary.enabled.bg).toBe('#e11d48') + expect(theme.v2?.color.dark.default.button.default.primary.enabled.bg).not.toBe( + theme.v2?.color.light.default.button.default.primary.enabled.bg, + ) + }) +}) diff --git a/plugins/@sanity/themer/src/tool/AnimatedColorWheelIcon.tsx b/plugins/@sanity/themer/src/tool/AnimatedColorWheelIcon.tsx new file mode 100644 index 0000000000..4a832f2874 --- /dev/null +++ b/plugins/@sanity/themer/src/tool/AnimatedColorWheelIcon.tsx @@ -0,0 +1,93 @@ +import {usePrefersReducedMotion} from '@sanity/ui' +import {motion, MotionValue, useTransform} from 'motion/react' + +import {isSliceFilled, SLICE_COLORS, SLICE_COUNT, wheelRotation} from './colorWheel' + +/** + * The geometry of `ColorWheelIcon` from `@sanity/icons`: a ring between two + * circles around the center of a 25×25 viewBox, cut into eight slices by the + * spokes of its outline path + */ +const CENTER = 12.5 +const OUTER_RADIUS = 8 +const INNER_RADIUS = 3.5 +const OUTLINE = + 'M9.43853 5.10896L11.1606 9.26642M13.8394 15.7336L15.5615 19.891M15.7336 11.1606L19.891 9.43853M9.26642 13.8394L5.10896 15.5615M5.3139 9.52342L9.23359 11.147M15.7664 13.853L19.6861 15.4766M13.853 9.23359L15.4766 5.3139M9.52342 19.6861L11.147 15.7664M20.5 12.5C20.5 16.9183 16.9183 20.5 12.5 20.5C8.08172 20.5 4.5 16.9183 4.5 12.5C4.5 8.08172 8.08172 4.5 12.5 4.5C16.9183 4.5 20.5 8.08172 20.5 12.5ZM16 12.5C16 14.433 14.433 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9 12.5 9C14.433 9 16 10.567 16 12.5Z' + +const SLICE_ANGLE = 360 / SLICE_COUNT + +/** A point on a circle around the center, at `degrees` clockwise from 3 o'clock */ +function point(radius: number, degrees: number): string { + const radians = (degrees * Math.PI) / 180 + + return `${CENTER + radius * Math.cos(radians)} ${CENTER + radius * Math.sin(radians)}` +} + +/** + * The part of the ring a slice colors in: between its two spokes, from the + * inner circle out to the outer. The 12 o'clock slice is centered on 12, so + * it starts half a slice before it. + */ +function slicePath(index: number): string { + const start = index * SLICE_ANGLE - SLICE_ANGLE / 2 - 90 + const end = start + SLICE_ANGLE + + return [ + `M${point(OUTER_RADIUS, start)}`, + `A${OUTER_RADIUS} ${OUTER_RADIUS} 0 0 1 ${point(OUTER_RADIUS, end)}`, + `L${point(INNER_RADIUS, end)}`, + `A${INNER_RADIUS} ${INNER_RADIUS} 0 0 0 ${point(INNER_RADIUS, start)}`, + 'Z', + ].join('') +} + +const SLICE_PATHS = SLICE_COLORS.map((_, index) => slicePath(index)) + +/** + * A filled shape, not a stroked one: the icon styles of `@sanity/ui` force + * `vector-effect: non-scaling-stroke` on every path in an icon, which fills + * are none the wiser about + */ +function Slice(props: {index: number; progress: MotionValue}) { + const {index, progress} = props + const opacity = useTransform(progress, (value) => (isSliceFilled(value, index) ? 1 : 0)) + + return +} + +/** + * The `ColorWheelIcon` of `@sanity/icons`, with an animation driven by + * `progress` (0–1): the slices pop in with color one at a time, clockwise + * from 12 o'clock like the segments of a classic spinner, the wheel spins once + * as the 6 o'clock slice pops in, and the colors pop out the same way round, + * carrying the spin's speed to a rest (see `colorWheel.ts`). At rest — at + * either end — it looks exactly like the icon it stands in for. With + * `prefers-reduced-motion: reduce` the wheel keeps still; only the colors come + * and go. + * + * @internal + */ +export function AnimatedColorWheelIcon(props: {progress: MotionValue}) { + const {progress} = props + const prefersReducedMotion = usePrefersReducedMotion() + const rotate = useTransform(progress, (value) => + prefersReducedMotion ? 0 : wheelRotation(value), + ) + + return ( + + {SLICE_COLORS.map((color, index) => ( + + ))} + + + ) +} diff --git a/plugins/@sanity/themer/src/tool/ImageFileButton.tsx b/plugins/@sanity/themer/src/tool/ImageFileButton.tsx new file mode 100644 index 0000000000..118b0c54a8 --- /dev/null +++ b/plugins/@sanity/themer/src/tool/ImageFileButton.tsx @@ -0,0 +1,41 @@ +import {ImageIcon} from '@sanity/icons/Image' +import type {ButtonProps} from '@sanity/ui' +import {useRef} from 'react' + +import {TooltipButton} from './TooltipButton' + +/** + * A button that opens the file picker for an image, and hands the chosen file + * over — the picker is a hidden file input, so nothing is uploaded anywhere. + * + * @internal + */ +export function ImageFileButton( + props: {onFile: (file: File) => void; tooltip: string} & Pick< + ButtonProps, + 'disabled' | 'loading' | 'mode' | 'padding' | 'text' | 'width' + >, +) { + const {onFile, ...buttonProps} = props + const inputRef = useRef(null) + + return ( + <> + inputRef.current?.click()} /> + { + const file = event.currentTarget.files?.[0] + + // Picking the same file again should work, so the input forgets it + event.currentTarget.value = '' + + if (file) onFile(file) + }} + ref={inputRef} + type="file" + /> + + ) +} diff --git a/plugins/@sanity/themer/src/tool/ImagePaletteSection.css.ts b/plugins/@sanity/themer/src/tool/ImagePaletteSection.css.ts new file mode 100644 index 0000000000..8d68bcc86b --- /dev/null +++ b/plugins/@sanity/themer/src/tool/ImagePaletteSection.css.ts @@ -0,0 +1,126 @@ +import {createVar, style} from '@vanilla-extract/css' + +const TILE_SIZE = 24 +const TILE_GAP = 4 + +/** The image spans both rows of tiles */ +const IMAGE_SIZE = TILE_SIZE * 2 + TILE_GAP + +/** The variant previews are the theme thumbnails, laid out at this width and scaled down */ +export const VARIANT_WIDTH = 144 + +export const VARIANT_GAP = 8 +export const ROW_PADDING = 12 + +/** The frame around a variant's thumbnail, on either side */ +export const VARIANT_FRAME = 4 + +/** The width of one variant's thumbnail, set on the row once it is measured */ +export const variantWidth = createVar() + +/** How much a variant's thumbnail is scaled down from `VARIANT_WIDTH` */ +export const variantScale = createVar() + +export const paletteGrid = style({ + display: 'grid', + gridTemplateColumns: `repeat(3, ${TILE_SIZE}px)`, + gridAutoRows: TILE_SIZE, + gap: TILE_GAP, + selectors: { + "&[data-with-image='true']": { + gridTemplateColumns: `${IMAGE_SIZE}px repeat(3, ${TILE_SIZE}px)`, + }, + }, +}) + +export const imageTile = style({ + display: 'block', + gridRow: 'span 2', + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: 3, + objectFit: 'cover', + boxShadow: 'inset 0 0 0 1px var(--card-border-color)', +}) + +export const tile = style({ + display: 'block', + borderRadius: 3, + boxShadow: 'inset 0 0 0 1px var(--card-border-color)', + selectors: { + "&[data-empty='true']": { + background: + 'repeating-linear-gradient(-45deg, transparent 0 3px, var(--card-border-color) 3px 4px)', + }, + }, +}) + +/** + * The variants scroll sideways and snap into place, bleeding into the card's + * padding so the row runs from edge to edge + */ +export const variantRow = style({ + display: 'flex', + gap: VARIANT_GAP, + margin: `0 -${ROW_PADDING}px`, + padding: `0 ${ROW_PADDING}px`, + overflowX: 'auto', + scrollSnapType: 'x mandatory', + scrollPaddingLeft: ROW_PADDING, + scrollbarWidth: 'thin', +}) + +export const variantButton = style({ + 'appearance': 'none', + 'flex': 'none', + 'display': 'block', + 'boxSizing': 'border-box', + 'width': `calc(${variantWidth} + ${VARIANT_FRAME * 2}px)`, + 'margin': 0, + 'padding': 0, + 'border': 0, + 'background': 'none', + 'color': 'inherit', + 'font': 'inherit', + 'textAlign': 'center', + 'cursor': 'pointer', + 'scrollSnapAlign': 'start', + ':focus': { + outline: 'none', + }, +}) + +/** + * Clips the scaled-down thumbnail to its visual size, with room for the ring + * — drawn inside the frame, like the theme cards', so the scrolling row + * never clips it + */ +export const variantFrame = style({ + display: 'block', + boxSizing: 'border-box', + width: `calc(${variantWidth} + ${VARIANT_FRAME * 2}px)`, + height: `calc(${variantWidth} * 9 / 16 + ${VARIANT_FRAME * 2}px)`, + padding: VARIANT_FRAME, + borderRadius: 7, + overflow: 'hidden', + transition: 'box-shadow 100ms', + selectors: { + [`${variantButton}:hover &`]: { + boxShadow: 'inset 0 0 0 2px var(--card-border-color)', + }, + [`${variantButton}[aria-pressed='true'] &`]: { + boxShadow: 'inset 0 0 0 2px var(--card-focus-ring-color)', + }, + [`${variantButton}:focus-visible &`]: { + outline: '2px solid var(--card-focus-ring-color)', + outlineOffset: -2, + }, + }, +}) + +export const variantScaleBox = style({ + display: 'block', + width: VARIANT_WIDTH, + transform: `scale(${variantScale})`, + transformOrigin: 'top left', +}) diff --git a/plugins/@sanity/themer/src/tool/ImagePaletteSection.tsx b/plugins/@sanity/themer/src/tool/ImagePaletteSection.tsx new file mode 100644 index 0000000000..bfae7e756c --- /dev/null +++ b/plugins/@sanity/themer/src/tool/ImagePaletteSection.tsx @@ -0,0 +1,271 @@ +import {SparklesIcon} from '@sanity/icons/Sparkles' +import {Box, Card, Flex, Stack, Text} from '@sanity/ui' +import {Tooltip} from '@sanity/ui/tooltip' +import {assignInlineVars} from '@vanilla-extract/dynamic' +import {useEffect, useMemo, useRef, useState} from 'react' + +import type {BuildThemeOptions} from '../theme/options' +import {ImageFileButton} from './ImageFileButton' +import { + applyImagePalette, + currentImageVariant, + IMAGE_PALETTE_TITLES, + IMAGE_PALETTE_VARIANTS, + type ImagePalette, + type ImagePaletteKey, + type ImagePaletteVariant, +} from './imagePalette' +import {ThemeThumbnail} from './ThemeThumbnail' +import {TooltipButton} from './TooltipButton' +import {useImagePalette} from './useImagePalette' + +import { + imageTile, + paletteGrid, + ROW_PADDING, + tile, + VARIANT_FRAME, + VARIANT_GAP, + VARIANT_WIDTH, + variantButton, + variantFrame, + variantRow, + variantScale, + variantScaleBox, + variantWidth, +} from './ImagePaletteSection.css' + +/** The swatches on display: the vibrant family over the muted one */ +const TILES: ImagePaletteKey[] = [ + 'vibrant', + 'lightVibrant', + 'darkVibrant', + 'muted', + 'lightMuted', + 'darkMuted', +] + +/** + * How many variants show at once: two and a half, so that the cut-off third + * one gives away that the row scrolls + */ +const VISIBLE_VARIANTS = 2.5 + +/** The width of one variant, before the row has been measured */ +const FALLBACK_VARIANT_WIDTH = 58 + +/** + * Takes the colors of a theme from an image, on device: the image is read + * into a canvas and its palette extracted right there — nothing is uploaded. + * Shows the image (while the session lasts) next to its palette, offers a + * theme variant built around each swatch as a small preview, and a lucky + * button that picks an interesting one. + * + * @internal + */ +export function ImagePaletteSection(props: { + imageUrl?: string + onPalette: (palette: ImagePalette, file: File) => void + onVariant: (variant: ImagePaletteVariant) => void + onLucky: () => void + options: BuildThemeOptions + palette?: ImagePalette +}) { + const {imageUrl, onPalette, onVariant, onLucky, options, palette} = props + const {busy, pickImage} = useImagePalette(onPalette) + const [dragging, setDragging] = useState(false) + // `dragleave` also fires when the pointer crosses into a child, so the + // highlight tracks how deep the drag is rather than the last event + const dragDepth = useRef(0) + const current = palette ? currentImageVariant(options, palette) : null + const rowRef = useRef(null) + const [thumbnailWidth, setThumbnailWidth] = useState(FALLBACK_VARIANT_WIDTH) + + // Size the variants to the row, so two and a half of them are in view + useEffect(() => { + const row = rowRef.current + + if (!row) return undefined + + const measure = () => { + const visible = + row.clientWidth - ROW_PADDING - (Math.ceil(VISIBLE_VARIANTS) - 1) * VARIANT_GAP + + // Each variant is its thumbnail plus the frame on either side + setThumbnailWidth(Math.max(24, Math.floor(visible / VISIBLE_VARIANTS) - VARIANT_FRAME * 2)) + } + + measure() + + const observer = new ResizeObserver(measure) + + observer.observe(row) + + return () => observer.disconnect() + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- the row only renders with a palette, so a new palette has a new row to observe + }, [palette]) + + const variants = useMemo(() => { + if (!palette) return [] + + return IMAGE_PALETTE_VARIANTS.flatMap((variant) => + palette[variant] === null + ? [] + : [ + { + variant, + title: IMAGE_PALETTE_TITLES[variant], + options: applyImagePalette(options, palette, variant), + }, + ], + ) + }, [options, palette]) + + return ( + { + event.preventDefault() + dragDepth.current++ + setDragging(true) + }} + onDragLeave={() => { + dragDepth.current = Math.max(0, dragDepth.current - 1) + + if (dragDepth.current === 0) setDragging(false) + }} + onDragOver={(event) => { + event.preventDefault() + }} + onDrop={(event) => { + event.preventDefault() + dragDepth.current = 0 + setDragging(false) + + const file = event.dataTransfer.files[0] + + if (file) pickImage(file) + }} + padding={3} + radius={3} + tone={dragging ? 'primary' : 'default'} + > + + + + + Image palette + + + {palette && ( + + )} + + + {palette ? ( + <> +
+ {imageUrl && } + {TILES.map((key) => { + const hex = palette[key] + const title = IMAGE_PALETTE_TITLES[key] + + return ( + + {hex ? `${title} · ${hex}` : `No ${title.toLowerCase()} color`} + + } + key={key} + placement="bottom" + portal + > + + + ) + })} +
+ + + + Pick the swatch to build the theme around + +
+ {variants.map(({variant, title, options: variantOptions}) => ( + // oxlint-disable-next-line control-has-associated-label -- the title below the thumbnail is the label, deeper than the rule looks + + ))} +
+
+ + + + ) : ( + + + Take the accent, text and background colors from an image — read on device, nothing is + uploaded. + + + + )} +
+
+ ) +} diff --git a/plugins/@sanity/themer/src/tool/PasteThemeDialog.tsx b/plugins/@sanity/themer/src/tool/PasteThemeDialog.tsx new file mode 100644 index 0000000000..0c2806ae62 --- /dev/null +++ b/plugins/@sanity/themer/src/tool/PasteThemeDialog.tsx @@ -0,0 +1,83 @@ +import {Button, Card, Dialog, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {useState} from 'react' + +import {useThemeCodes} from './useThemeCodes' + +/** + * Where a theme code someone shared gets pasted by hand — for browsers that + * do not hand the clipboard over, and for codes that are not on it. + * + * @internal + */ +export function PasteThemeDialog(props: {onClose: () => void}) { + const {onClose} = props + const {addThemeFromText} = useThemeCodes() + const [text, setText] = useState('') + const [invalid, setInvalid] = useState(false) + + const add = () => { + if (addThemeFromText(text)) { + onClose() + } else { + setInvalid(true) + } + } + + return ( + + + ) +} diff --git a/plugins/@sanity/themer/src/tool/RemovedThemes.css.ts b/plugins/@sanity/themer/src/tool/RemovedThemes.css.ts new file mode 100644 index 0000000000..fa858730e8 --- /dev/null +++ b/plugins/@sanity/themer/src/tool/RemovedThemes.css.ts @@ -0,0 +1,6 @@ +import {style} from '@vanilla-extract/css' + +/** A removed theme's thumbnail, dimmed to read as removed */ +export const removedThumbnail = style({ + opacity: 0.6, +}) diff --git a/plugins/@sanity/themer/src/tool/RemovedThemes.tsx b/plugins/@sanity/themer/src/tool/RemovedThemes.tsx new file mode 100644 index 0000000000..cfcdf5f06a --- /dev/null +++ b/plugins/@sanity/themer/src/tool/RemovedThemes.tsx @@ -0,0 +1,54 @@ +import {Box, Button, Flex, Stack, Text} from '@sanity/ui' + +import {useThemer} from './context' +import {ScrollArea} from './ScrollArea' +import {displayTitle} from './themes' +import {ThemeThumbnail} from './ThemeThumbnail' + +import {removedThumbnail} from './RemovedThemes.css' + +/** + * The flow for restoring removed themes: the presets and custom themes the + * user took out of the list, each with a button to put it back — and, for + * custom themes, one to delete it for good. The machine leaves the flow once + * the last one is gone. + * + * @internal + */ +export function RemovedThemes() { + const {removed, send} = useThemer() + + return ( + + + {removed.map((theme) => ( + + + + + + {displayTitle(theme.title)} + + + + + {/* The tooltip wraps a card rather than the button: the menu button + clones its button to wire it up, which a tooltip in between would + swallow. The card keeps the button dark, as it sits on the dark + half of the thumbnail, while the menu opens in the sidebar's scheme */} +
+ Show more} placement="bottom" portal> + + + } + id={`themer-theme-${theme.slug}`} + menu={ + + moveTo(index - 1)} + text="Move up" + /> + = themes.length - 1} + icon={ArrowDownIcon} + onClick={() => moveTo(index + 1)} + text="Move down" + /> + + {theme.source === 'custom' && ( + send({type: 'theme.edit', slug: theme.slug})} + text="Edit" + /> + )} + send({type: 'theme.duplicate', slug: theme.slug})} + text={theme.source === 'custom' ? 'Duplicate' : 'Duplicate to edit'} + /> + void copyTheme(theme)} + text="Copy to share" + /> + {theme.source !== 'config' && ( + <> + + send({type: 'theme.remove', slug: theme.slug})} + text="Remove" + tone="critical" + /> + + )} + + } + onClose={() => setMenuOpen(false)} + onOpen={() => setMenuOpen(true)} + popover={{animate: true, placement: 'bottom-end', portal: true, scheme}} + /> + + +
+ + ) +} diff --git a/plugins/@sanity/themer/src/tool/ThemeEditor.css.ts b/plugins/@sanity/themer/src/tool/ThemeEditor.css.ts new file mode 100644 index 0000000000..b4a5de442c --- /dev/null +++ b/plugins/@sanity/themer/src/tool/ThemeEditor.css.ts @@ -0,0 +1,40 @@ +import {style} from '@vanilla-extract/css' + +/** A native color input, trimmed down to its swatch */ +export const swatch = style({ + boxSizing: 'border-box', + flex: 'none', + width: 33, + height: 33, + padding: 0, + border: '1px solid var(--card-border-color)', + borderRadius: 4, + background: 'none', + cursor: 'pointer', + selectors: { + '&::-webkit-color-swatch-wrapper': { + padding: 0, + }, + '&::-webkit-color-swatch': { + border: 'none', + borderRadius: 3, + }, + '&::-moz-color-swatch': { + border: 'none', + borderRadius: 3, + }, + }, +}) + +/** A native range input, themed through `accent-color` */ +export const range = style({ + display: 'block', + width: '100%', + margin: 0, + accentColor: 'var(--card-focus-ring-color)', +}) + +/** Lets the color row's text shrink and truncate instead of widening the row */ +export const colorRowText = style({ + minWidth: 0, +}) diff --git a/plugins/@sanity/themer/src/tool/ThemeEditor.tsx b/plugins/@sanity/themer/src/tool/ThemeEditor.tsx new file mode 100644 index 0000000000..84acbf29f3 --- /dev/null +++ b/plugins/@sanity/themer/src/tool/ThemeEditor.tsx @@ -0,0 +1,406 @@ +import {ResetIcon} from '@sanity/icons/Reset' +import {TrashIcon} from '@sanity/icons/Trash' +import {Badge, Box, Button, Card, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {type ThemeColorSchemeKey} from '@sanity/ui/theme' +import {useMemo} from 'react' +import {useColorSchemeValue} from 'sanity' + +import {buildPalette, type GeneratedColorPalette} from '../theme/buildPalette' +import { + type BuildThemeOptions, + type ResolvedSchemeOptions, + resolveThemeOptions, + SCHEMES, + type SchemeThemeOptions, +} from '../theme/options' +import {useThemer} from './context' +import { + applyImagePalette, + currentImageVariant, + type ImagePalette, + type ImagePaletteVariant, + pickLuckyVariant, +} from './imagePalette' +import {ImagePaletteSection} from './ImagePaletteSection' +import {ScrollArea, ScrollAreaBleed} from './ScrollArea' +import {ThemeThumbnail} from './ThemeThumbnail' +import {TooltipButton} from './TooltipButton' + +import {colorRowText, range, swatch} from './ThemeEditor.css' + +const SCHEME_TITLES: Record = { + dark: 'Dark mode', + light: 'Light mode', +} + +/** + * Where the split preview shows a scheme: the Studio's own scheme stays next + * to the sidebar (or at the bottom, stacked on small screens), the other one + * takes the far side + */ +function splitHint(own: boolean, mobile: boolean): string { + if (mobile) return own ? 'Shown at the bottom' : 'Shown at the top' + + return own ? 'Shown on the right, next to the themer' : 'Shown on the left' +} + +const SCHEME_OPTION_KEYS: Array = [ + 'accent', + 'text', + 'background', + 'contrast', +] + +/** + * `` paints the color into a shadow-DOM swatch that brings + * its own border and padding, which then sits inside ours as a second border. + * Stripping that chrome leaves the themed border as the only one. + */ +/** + * The flow for editing one of the user's own themes: its title, a live + * preview, and a card per color scheme with the accent/text/background + * pickers and the contrast slider of that scheme. Every change applies to the + * whole Studio right away — in the scheme the Studio is showing, or in both + * side by side in the split preview. + * + * @internal + */ +export function ThemeEditor(props: {focusTitle: boolean; slug: string}) { + const {focusTitle, slug} = props + const {themes, images, split, mobile, send} = useThemer() + const theme = themes.find((candidate) => candidate.slug === slug && candidate.source === 'custom') + + // The machine only enters the edit flow for a listed custom theme, and + // leaves it as soon as the theme is removed or deleted + if (!theme) return null + + const applyVariant = (variant: ImagePaletteVariant | null) => { + if (!theme.palette || variant === null) return + + send({ + type: 'theme.update', + slug, + options: applyImagePalette(theme.options, theme.palette, variant), + }) + } + + return ( + send({type: 'flow.list'})} + onLucky={() => { + if (!theme.palette) return + + applyVariant( + pickLuckyVariant(theme.palette, { + exclude: currentImageVariant(theme.options, theme.palette), + }), + ) + }} + onOptionsChange={(options) => send({type: 'theme.update', slug, options})} + onPalette={(palette, file) => + send({ + type: 'theme.update', + slug, + options: applyImagePalette(theme.options, palette), + palette, + imageUrl: URL.createObjectURL(file), + }) + } + onRemove={() => send({type: 'theme.remove', slug})} + onTitleChange={(title) => send({type: 'theme.update', slug, title})} + onVariant={applyVariant} + options={theme.options} + palette={theme.palette} + split={split} + mobile={mobile} + title={theme.title} + /> + ) +} + +// Split from `ThemeEditor` so that the derived values below can be hooks +// while the editor bails out early when the theme is gone +function ThemeEditorForm(props: { + focusTitle: boolean + imageUrl?: string + onDone: () => void + onLucky: () => void + onOptionsChange: (options: BuildThemeOptions) => void + onPalette: (palette: ImagePalette, file: File) => void + onRemove: () => void + onTitleChange: (title: string) => void + onVariant: (variant: ImagePaletteVariant) => void + options: BuildThemeOptions + palette?: ImagePalette + /** Whether the Studio shows in light and dark side by side */ + split: boolean + /** Whether the split preview stacks, on a small screen */ + mobile: boolean + title: string +}) { + const { + focusTitle, + imageUrl, + onDone, + onLucky, + onOptionsChange, + onPalette, + onRemove, + onTitleChange, + onVariant, + options, + palette, + split, + mobile, + title, + } = props + // The scheme the Studio is showing, with the appearance setting resolved + const studioScheme = useColorSchemeValue() + const resolved = useMemo(() => resolveThemeOptions(options), [options]) + const palettes = useMemo(() => buildPalette(options), [options]) + + const patchScheme = (scheme: ThemeColorSchemeKey, changes: Partial) => { + const values: SchemeThemeOptions = {...options[scheme], ...changes} + + // Clearing an option means falling back to its default, which is its absence + for (const key of SCHEME_OPTION_KEYS) { + if (values[key] === undefined) delete values[key] + } + + const next: BuildThemeOptions = {...options} + + if (Object.keys(values).length === 0) { + delete next[scheme] + } else { + next[scheme] = values + } + + onOptionsChange(next) + } + + return ( + <> + + + + + + + Title + + onTitleChange(event.currentTarget.value)} + onFocus={focusTitle ? (event) => event.currentTarget.select() : undefined} + padding={2} + placeholder="Untitled theme" + value={title} + /> + + + + + + {SCHEMES.map((scheme) => ( + patchScheme(scheme, changes)} + options={options[scheme] ?? {}} + palette={palettes[scheme]} + resolved={resolved[scheme]} + scheme={scheme} + split={split} + splitHint={splitHint(scheme === studioScheme, mobile)} + /> + ))} + + + + + + + + +