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/dashboard-widget-vercel-xstate.md b/.changeset/dashboard-widget-vercel-xstate.md new file mode 100644 index 0000000000..f2a09b5e9b --- /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/shared-deps-sanity-ui-icons-motion.md b/.changeset/shared-deps-sanity-ui-icons-motion.md new file mode 100644 index 0000000000..0f066d4cbe --- /dev/null +++ b/.changeset/shared-deps-sanity-ui-icons-motion.md @@ -0,0 +1,47 @@ +--- +"@sanity/assist": patch +"@sanity/block-insert-picker": patch +"@sanity/code-input": patch +"@sanity/color-input": patch +"@sanity/cross-dataset-duplicator": patch +"@sanity/dashboard": patch +"@sanity/debug-live-sync-tags": patch +"@sanity/debug-preview-url-secret-plugin": patch +"@sanity/document-internationalization": patch +"@sanity/embeddings-index-ui": patch +"@sanity/form-toolkit": patch +"@sanity/google-maps-input": patch +"@sanity/hierarchical-document-list": patch +"@sanity/language-filter": patch +"@sanity/orderable-document-list": patch +"@sanity/personalization-plugin": patch +"@sanity/rich-date-input": patch +"@sanity/sanity-plugin-async-list": patch +"@sanity/sfcc": patch +"@sanity/studio-secrets": patch +"@sanity/table": patch +"@sanity/vercel-protection-bypass": patch +"sanity-plugin-aprimo": patch +"sanity-plugin-asset-source-unsplash": patch +"sanity-plugin-bynder-input": patch +"sanity-plugin-cloudinary": patch +"sanity-plugin-dashboard-widget-document-list": patch +"sanity-plugin-dashboard-widget-netlify": patch +"sanity-plugin-dashboard-widget-vercel": patch +"sanity-plugin-documents-pane": patch +"sanity-plugin-google-translate": patch +"sanity-plugin-graph-view": patch +"sanity-plugin-hotspot-array": patch +"sanity-plugin-iframe-pane": patch +"sanity-plugin-internationalized-array": patch +"sanity-plugin-markdown": patch +"sanity-plugin-media": patch +"sanity-plugin-mux-input": patch +"sanity-plugin-shopify-assets": patch +"sanity-plugin-utils": patch +"sanity-plugin-workflow": patch +"sanity-plugin-workspace-home": patch +"sanity-translations-tab": patch +--- + +Raise the minimum versions of the `@sanity/ui` (4.2.7), `@sanity/icons` (5.2.2) and `motion` (13.4.4) dependencies the plugin uses diff --git a/.changeset/themer-plugins-monorepo.md b/.changeset/themer-plugins-monorepo.md new file mode 100644 index 0000000000..211d813d9a --- /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/.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/ci.yml b/.github/workflows/ci.yml index 60e6d06aa0..add5bf3bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,37 @@ jobs: - name: Run Knip run: pnpm knip --reporter github-actions + storybook-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + - name: Store Playwright's version + id: playwright-version + run: | + PLAYWRIGHT_VERSION=$(pnpm --filter plugins-storybook exec playwright --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + echo "Playwright's version: $PLAYWRIGHT_VERSION" + echo "version=${PLAYWRIGHT_VERSION}" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers for Playwright's version + id: cache-playwright-browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: ${{ steps.playwright-version.outputs.version }}-playwright-browsers-chromium + - name: Install Playwright browsers + if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' + run: pnpm --filter plugins-storybook exec playwright install --with-deps chromium + - name: Install Playwright system deps (cache hit) + if: steps.cache-playwright-browsers.outputs.cache-hit == 'true' + run: pnpm --filter plugins-storybook exec playwright install-deps chromium + - run: pnpm test:browser + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: vitest-failure-screenshots + path: dev/storybook/**/__screenshots__/ + if-no-files-found: ignore + test_shards: name: Test (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: ubuntu-latest 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/.gitignore b/.gitignore index e8f4c86ed1..5fc7c3a3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,13 @@ yalc.lock # Sanity Studio .sanity +# Storybook +storybook-static + +# Vitest browser mode failure screenshots and attachments +__screenshots__ +.vitest-attachments + # pnpm .pnpm-store diff --git a/AGENTS.md b/AGENTS.md index 57c7c985a8..d0777b2d8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ This guide is for AI agents working on this codebase. Follow these instructions | Cleanup e2e datasets | `pnpm e2e:cleanup` | | Add changeset | `pnpm changeset add` | | Start dev server | `pnpm dev` | +| Start Storybook | `pnpm dev:storybook` | +| Run Storybook tests | `pnpm test:browser` | ## Environment Setup @@ -74,6 +76,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. @@ -210,12 +214,13 @@ Two things are required: The CI pipeline runs on every PR: -| Job | What it checks | -| --------- | ------------------------------------------------------ | -| **build** | `pnpm build` - All packages compile successfully | -| **lint** | `pnpm lint --format github` - Code passes oxlint | -| **knip** | `pnpm knip` - No unused files, dependencies or exports | -| **test** | `pnpm test` - All tests pass (runs after build + lint) | +| Job | What it checks | +| ------------------ | -------------------------------------------------------------- | +| **build** | `pnpm build` - All packages compile successfully | +| **lint** | `pnpm lint --format github` - Code passes oxlint | +| **knip** | `pnpm knip` - No unused files, dependencies or exports | +| **test** | `pnpm test` - All tests pass (runs after build + lint) | +| **storybook-test** | `pnpm test:browser` - Every story renders in headless Chromium | Note on **knip**: in-file usage keeps an exported type "used" (`ignoreExportsUsedInFile`), so removing a type assertion or annotation that was the last reference to an exported type will make knip start flagging that export. Run `pnpm knip` after refactors that remove type references. @@ -376,6 +381,10 @@ How it works: React profiling is already available in the development build from `pnpm dev`. `VERCEL_ENV` is listed in `dev/test-studio/turbo.jsonc` so Turbo cache keys differ between Vercel and non-Vercel builds. +### Storybook + +`dev/storybook` is a React Storybook set up like sanity-io/ui's `apps/storybook`, next to the other dev apps, with stories in `dev/storybook/stories//` (see its `README.md`). `pnpm dev:storybook` serves it at `http://localhost:6006` without Sanity authentication, `pnpm test:browser` renders every story in headless Chromium through the Storybook Vitest addon (the `storybook-test` CI job), and `pnpm storybook:build` writes the static build to `dev/storybook/storybook-static`. Install the browser once with `pnpm --filter plugins-storybook exec playwright install chromium`. The preview imports `@sanity/ui/styles.css` because the published `@sanity/ui` dist doesn't import its own CSS, and it keeps the vanilla-extract Vite plugin because workspace plugins resolve to their `.css.ts` source. Like the other `dev/*` apps, it's skipped by `pretest` and the CI build job. + ## Creating a New Plugin Use the generator: @@ -558,6 +567,7 @@ The shared rules (plugins, options, categories, rules) live in the `@sanity/plug ``` plugins/ +├── dev/storybook/ # React Storybook for plugin components (localhost:6006) ├── dev/test-studio/ # Test Sanity Studio (localhost:3333) ├── packages/@repo/ # Internal shared packages ├── packages/@sanity/ # Published tooling packages (e.g. @sanity/plugin-kit) @@ -600,9 +610,10 @@ corepack enable ### Services -| Service | Port | Purpose | -| ------------------------ | ---- | --------------------------------------------- | -| Test Studio (`pnpm dev`) | 3333 | Local Sanity Studio for manual plugin testing | +| Service | Port | Purpose | +| -------------------------------- | ---- | ----------------------------------------------- | +| Test Studio (`pnpm dev`) | 3333 | Local Sanity Studio for manual plugin testing | +| Storybook (`pnpm dev:storybook`) | 6006 | Plugin component stories, no Sanity auth needed | No Docker, databases, or other local services are required. CI-style verification (`pnpm lint`, `pnpm build`, `pnpm test run`) runs entirely in-process. diff --git a/README.md b/README.md index 19bd75c8b5..f266cba851 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/storybook/.storybook/decorators/withSanityTheme.decorator.tsx b/dev/storybook/.storybook/decorators/withSanityTheme.decorator.tsx new file mode 100644 index 0000000000..3221664d18 --- /dev/null +++ b/dev/storybook/.storybook/decorators/withSanityTheme.decorator.tsx @@ -0,0 +1,70 @@ +import {buildTheme, presets} from '@sanity/themer' +import {Card, ThemeProvider} from '@sanity/ui' +import type {RootTheme, ThemeColorSchemeKey} from '@sanity/ui/theme' +import {DecoratorHelpers} from '@storybook/addon-themes' +import type {Decorator} from '@storybook/react-vite' +import {createGlobalStyle} from 'styled-components' + +const {initializeThemeState, pluckThemeFromContext} = DecoratorHelpers + +const GlobalStyle = createGlobalStyle` + body, + .docs-story { + background-color: ${ + // oxlint-disable-next-line no-deprecated + ({theme}) => theme.sanity.color.base.bg + }; + } +` + +const presetThemes = new Map() + +/** The theme `@sanity/themer` builds from the preset with the given slug, built once */ +function getPresetTheme(slug: string): RootTheme { + const cached = presetThemes.get(slug) + if (cached) return cached + + const preset = presets.find((candidate) => candidate.slug === slug) + const theme = buildTheme(preset?.options) + presetThemes.set(slug, theme) + + return theme +} + +/** + * Story decorator which wraps all stories in a Sanity with the theme that + * `buildTheme` from `@sanity/themer` generates from the preset picked in the toolbar, in the + * color scheme picked next to it. + * + * Stories are also wrapped in a for layout. Set the `padding` parameter to change (or + * remove, with `padding: 0`) the default padding – e.g. for stories that depend on exact + * viewport dimensions. + */ +export const withSanityTheme = ({ + themes, + defaultTheme, +}: { + themes: Record + defaultTheme: string +}): Decorator => { + initializeThemeState(Object.keys(themes), defaultTheme) + + return (Story, context) => { + const selectedTheme = pluckThemeFromContext(context) + const {themeOverride} = context.parameters['themes'] ?? {} + const {padding = 4} = context.parameters + const preset = String(context.globals['preset'] ?? 'studio') + + // oxlint-disable-next-line no-unsafe-type-assertion + const selected = (themeOverride || selectedTheme || defaultTheme) as ThemeColorSchemeKey + + return ( + + + + + + + ) + } +} diff --git a/dev/storybook/.storybook/main.ts b/dev/storybook/.storybook/main.ts new file mode 100644 index 0000000000..9c82e9380e --- /dev/null +++ b/dev/storybook/.storybook/main.ts @@ -0,0 +1,32 @@ +import {vanillaExtractPlugin} from '@sanity/vanilla-extract-vite-plugin' +import type {StorybookConfig} from '@storybook/react-vite' +import viteReact from '@vitejs/plugin-react' +import {mergeConfig} from 'vite' + +const config: StorybookConfig = { + stories: ['../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-a11y', + '@storybook/addon-docs', + '@storybook/addon-links', + '@storybook/addon-themes', + '@storybook/addon-vitest', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + viteFinal(config) { + return mergeConfig(config, { + plugins: [ + // `compiler` runs the React Compiler natively via `oxc-transform-react` + // (the Rust port) in the same pass as TypeScript/JSX — no babel + viteReact({compiler: {target: '19'}}), + // Workspace plugins resolve to their TypeScript source (dev `exports`), + // so their vanilla-extract `.css.ts` modules must be compiled here + vanillaExtractPlugin(), + ], + }) + }, +} +export default config diff --git a/dev/storybook/.storybook/manager-head.html b/dev/storybook/.storybook/manager-head.html new file mode 100644 index 0000000000..1cc63fd04c --- /dev/null +++ b/dev/storybook/.storybook/manager-head.html @@ -0,0 +1,81 @@ + + + + + + + + diff --git a/dev/storybook/.storybook/manager.ts b/dev/storybook/.storybook/manager.ts new file mode 100644 index 0000000000..bb13803002 --- /dev/null +++ b/dev/storybook/.storybook/manager.ts @@ -0,0 +1,11 @@ +import {addons} from 'storybook/manager-api' +import {themes} from 'storybook/theming' + +addons.setConfig({ + theme: { + ...themes.dark, + brandTitle: 'Sanity Plugins', + fontBase: + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Liberation Sans", Helvetica, Arial, system-ui, sans-serif', + }, +}) diff --git a/dev/storybook/.storybook/preview-head.html b/dev/storybook/.storybook/preview-head.html new file mode 100644 index 0000000000..a13ed5c046 --- /dev/null +++ b/dev/storybook/.storybook/preview-head.html @@ -0,0 +1,79 @@ + diff --git a/dev/storybook/.storybook/preview.tsx b/dev/storybook/.storybook/preview.tsx new file mode 100644 index 0000000000..756b296deb --- /dev/null +++ b/dev/storybook/.storybook/preview.tsx @@ -0,0 +1,53 @@ +import '@sanity/ui/styles.css' +import {presets} from '@sanity/themer' +import type {Preview} from '@storybook/react-vite' +import {themes} from 'storybook/theming' + +import {withSanityTheme} from './decorators/withSanityTheme.decorator' + +const preview: Preview = { + decorators: [ + withSanityTheme({ + themes: {light: 'light', dark: 'dark'}, + defaultTheme: 'dark', + }), + ], + globalTypes: { + preset: { + description: 'The @sanity/themer preset that the theme is built from', + toolbar: { + title: 'Preset', + icon: 'paintbrush', + items: presets.map((preset) => ({value: preset.slug, title: preset.title})), + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + preset: 'studio', + }, + parameters: { + actions: {argTypesRegex: '^on[A-Z].*'}, + backgrounds: {disabled: true}, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + docs: { + theme: { + ...themes.dark, + fontBase: 'Inter, sans-serif', + }, + }, + layout: 'fullscreen', + options: { + storySort: { + order: ['themer', '*'], + }, + }, + }, +} + +export default preview diff --git a/dev/storybook/README.md b/dev/storybook/README.md new file mode 100644 index 0000000000..e69603b5b6 --- /dev/null +++ b/dev/storybook/README.md @@ -0,0 +1,23 @@ +# Sanity Plugins Storybook + +A React [Storybook](https://storybook.js.org) for the plugins in this monorepo, set up like the [Sanity UI Storybook](https://github.com/sanity-io/ui/tree/main/apps/storybook). + +```sh +pnpm dev:storybook # http://localhost:6006 +pnpm test:browser # every story as a browser test +pnpm storybook:build # static build in dev/storybook/storybook-static +``` + +## Storybook guidelines + +- Stories live in `stories//`, e.g. `stories/themer/` for `@sanity/themer`. +- All stories must export either a named `Default` or `Basic` story. +- Avoid creating custom titles for stories - these should be inferred via folder structure alone. +- Where possible, stories should be kept as simple as possible with minimal custom / presentational props. +- Prefer setting component values via storybook [args](https://storybook.js.org/docs/react/writing-stories/args) instead of passing them manually in props. +- Stories import a plugin through its package name. Components that are not part of a plugin's public API (like the pieces of the `@sanity/themer` tool) are imported from the plugin's `src/` by relative path instead. + +## Things to note + +- All stories are wrapped with a [common decorator](https://storybook.js.org/docs/react/writing-stories/decorators#story-decorators) which wraps stories in both a `` but also a `` with padding. The theme is built by `buildTheme` from `@sanity/themer`, from the preset picked in the toolbar next to the light/dark scheme toggle. Stories that depend on exact viewport dimensions can opt out of the padding with the `padding: 0` parameter. +- Interaction tests are written as story `play` functions and run in a real browser with the [Vitest addon](https://storybook.js.org/docs/writing-tests/integrations/vitest-addon) (`pnpm test:browser`). Stories opt out of being tested with the `!test` tag. The Playwright-provided browser must be installed once via `pnpm --filter plugins-storybook exec playwright install chromium`. diff --git a/dev/storybook/package.json b/dev/storybook/package.json new file mode 100644 index 0000000000..6e108db104 --- /dev/null +++ b/dev/storybook/package.json @@ -0,0 +1,44 @@ +{ + "name": "plugins-storybook", + "version": "0.0.0", + "private": true, + "description": "Storybook for the Sanity plugins", + "license": "MIT", + "author": "Sanity.io ", + "type": "module", + "scripts": { + "build": "storybook build", + "dev": "storybook dev -p 6006", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sanity/color": "catalog:", + "@sanity/icons": "catalog:", + "@sanity/themer": "workspace:*", + "@sanity/tsconfig": "catalog:", + "@sanity/ui": "catalog:", + "@sanity/vanilla-extract-vite-plugin": "catalog:", + "@storybook/addon-a11y": "^10.6.0", + "@storybook/addon-docs": "^10.6.0", + "@storybook/addon-links": "^10.6.0", + "@storybook/addon-themes": "^10.6.0", + "@storybook/addon-vitest": "^10.6.0", + "@storybook/react-vite": "^10.6.0", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vanilla-extract/css": "catalog:", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/browser-playwright": "^4.1.11", + "motion": "catalog:", + "oxc-transform-react": "catalog:", + "playwright": "^1.63.0", + "react": "catalog:", + "react-dom": "catalog:", + "storybook": "^10.6.0", + "styled-components": "catalog:", + "vite": "^8.3.1", + "vitest": "catalog:" + } +} diff --git a/dev/storybook/stories/themer/theme/CanvasStory.tsx b/dev/storybook/stories/themer/theme/CanvasStory.tsx new file mode 100644 index 0000000000..17264312ed --- /dev/null +++ b/dev/storybook/stories/themer/theme/CanvasStory.tsx @@ -0,0 +1,282 @@ +import { + Box, + Card, + type CardTone, + Flex, + Grid, + Stack, + Text, + ThemeColorProvider, + useRootTheme, +} from '@sanity/ui' +import { + getContrastRatio, + mix, + parseColor, + rgbToHex, + type ThemeColorButton_v2, + type ThemeColorButtonMode_v2, + type ThemeColorButtonTone_v2, + type ThemeColorCard_v2, + type ThemeColorScheme_v2, + type ThemeColorSelectable_v2, + type ThemeColorSelectableTone_v2, + type ThemeColorState_v2, +} from '@sanity/ui/theme' + +export function CanvasStory() { + const {theme} = useRootTheme() + + return ( + + + + + + + + + + ) +} + +function ColorScheme(props: {scheme: ThemeColorScheme_v2}) { + const {scheme} = props + + return ( + + + + + + + + + ) +} + +function Color(props: {color: ThemeColorCard_v2; tone: CardTone}) { + const {color, tone} = props + + return ( + + + + + + + + Input + + + {/* @todo: remove use of `muted` here */} + + + + + + + + + + + + + + + ) +} + +function ColorBase(props: {color: ThemeColorCard_v2}) { + const {color} = props + + return ( + + Text + Muted + + Accent + + Link + + + Code + +
+ + ) +} + +function ColorButton(props: {color: ThemeColorButton_v2}) { + const {color} = props + + return ( + + + + + + ) +} + +function ColorButtonMode(props: {color: ThemeColorButtonMode_v2}) { + const {color} = props + + return ( + + + + + Enabled + + + + + Hovered + + + + + Pressed + + + + + Selected + + + + + Disabled + + + + + + + + + + ) +} + +function ColorGenericStates(props: {color: ThemeColorButtonTone_v2}) { + const {color} = props + + return ( + + + + + + + + ) +} + +function _contrast(baseBg: string, bg: string, fg: string) { + const baseBgRgb = parseColor(baseBg) + let bgRgb = parseColor(bg) + + if (typeof bgRgb.a === 'number') { + bgRgb = mix(baseBgRgb, bgRgb, bgRgb.a) + } + + return getContrastRatio(rgbToHex(bgRgb), fg) +} + +function ColorGenericState(props: {color: ThemeColorState_v2}) { + const {color} = props + + return ( + + + + + bg + + + + + bg2 + + + + + + Text – {_contrast(color.bg, color.bg, color.fg).toFixed(2)} + + + Muted – {_contrast(color.bg, color.bg, color.muted.fg).toFixed(2)} + + + Link – {_contrast(color.bg, color.bg, color.link.fg).toFixed(2)} + + + Accent – {_contrast(color.bg, color.bg, color.accent.fg).toFixed(2)} + + + Code –{' '} + {_contrast(color.bg, color.code.bg, color.code.fg).toFixed(2)} + +
+ + ) +} + +function ColorSelectable(props: {color: ThemeColorSelectable_v2}) { + const {color} = props + + return ( + + + + + + + + ) +} + +function ColorSelectableTone(props: {color: ThemeColorSelectableTone_v2}) { + const {color} = props + + return ( + + + + + + + + ) +} diff --git a/dev/storybook/stories/themer/theme/DebugStory.tsx b/dev/storybook/stories/themer/theme/DebugStory.tsx new file mode 100644 index 0000000000..bfbb52d075 --- /dev/null +++ b/dev/storybook/stories/themer/theme/DebugStory.tsx @@ -0,0 +1,273 @@ +import {CropIcon} from '@sanity/icons/Crop' +import {Badge, Box, Flex, KBD, Stack, Text, useRootTheme} from '@sanity/ui' +import { + THEME_COLOR_BUTTON_MODES, + THEME_COLOR_STATE_TONES, + THEME_COLOR_STATES, + type ThemeColorCard_v2, + type ThemeColorCardToneKey, + type ThemeColorScheme_v2, + type ThemeColorSchemeKey, + type ThemeColorState_v2, +} from '@sanity/ui/theme' +import type {CSSProperties} from 'react' + +type CSSPropertiesWithCustomProperties = CSSProperties & + Record<`--${string}`, string | number | undefined> + +interface Features { + base: boolean + button: boolean + schemes: ThemeColorSchemeKey[] + selectable: boolean + tones: ThemeColorCardToneKey[] +} + +const FEATURES: Features = { + base: true, + button: false, + schemes: ['light', 'dark'], + selectable: false, + tones: ['default'], +} + +export function DebugStory() { + const {color} = useRootTheme().theme.v2! + + return ( + + {FEATURES.schemes.map((scheme) => ( + + ))} + + ) +} + +function DebugScheme(props: {features: Features; scheme: ThemeColorScheme_v2}) { + const {features, scheme} = props + + return ( + + {features.tones.map((tone) => ( + + ))} + + ) +} + +function DebugCard(props: {card: ThemeColorCard_v2; features: Features}) { + const {card, features} = props + + return ( + + {features.base && } + + {features.button && ( + + {THEME_COLOR_BUTTON_MODES.map((mode) => ( + + {THEME_COLOR_STATE_TONES.map((stateTone) => ( + + {THEME_COLOR_STATES.map((state) => ( + + + + ))} + + ))} + + ))} + + )} + + {features.selectable && ( + + {THEME_COLOR_STATE_TONES.map((stateTone) => ( + + {THEME_COLOR_STATES.map((state) => ( + + + + ))} + + ))} + + )} + + ) +} + +const AVATAR_COLORS = [ + 'gray', + 'red', + 'orange', + 'yellow', + 'green', + 'cyan', + 'blue', + 'purple', + 'magenta', +] as const + +function DebugState() { + return ( + + + Text + + + + + + + Muted + + + + Accent + + + + Link + + + + Code + +
+ Cmd +
+ + {THEME_COLOR_STATE_TONES.map((stateTone) => ( + + {stateTone} + + ))} + +
+ + + + + + {AVATAR_COLORS.map((color) => ( + + + A + + + ))} + + + ) +} + +function getAvatarStyle(color: (typeof AVATAR_COLORS)[number]): CSSPropertiesWithCustomProperties { + return { + '--card-fg-color': `var(--card-avatar-${color}-fg-color)`, + 'backgroundColor': `var(--card-avatar-${color}-bg-color)`, + } +} + +function getStateVars(state: ThemeColorState_v2): CSSPropertiesWithCustomProperties { + return { + '--card-accent-fg-color': state.accent.fg, + + '--card-avatar-gray-bg-color': state.avatar.gray.bg, + '--card-avatar-gray-fg-color': state.avatar.gray.fg, + '--card-avatar-red-bg-color': state.avatar.red.bg, + '--card-avatar-red-fg-color': state.avatar.red.fg, + '--card-avatar-orange-bg-color': state.avatar.orange.bg, + '--card-avatar-orange-fg-color': state.avatar.orange.fg, + '--card-avatar-yellow-bg-color': state.avatar.yellow.bg, + '--card-avatar-yellow-fg-color': state.avatar.yellow.fg, + '--card-avatar-green-bg-color': state.avatar.green.bg, + '--card-avatar-green-fg-color': state.avatar.green.fg, + '--card-avatar-cyan-bg-color': state.avatar.cyan.bg, + '--card-avatar-cyan-fg-color': state.avatar.cyan.fg, + '--card-avatar-blue-bg-color': state.avatar.blue.bg, + '--card-avatar-blue-fg-color': state.avatar.blue.fg, + '--card-avatar-purple-bg-color': state.avatar.purple.bg, + '--card-avatar-purple-fg-color': state.avatar.purple.fg, + '--card-avatar-magenta-bg-color': state.avatar.magenta.bg, + '--card-avatar-magenta-fg-color': state.avatar.magenta.fg, + + '--card-badge-default-bg-color': state.badge.default.bg, + '--card-badge-default-dot-color': state.badge.default.dot, + '--card-badge-default-fg-color': state.badge.default.fg, + '--card-badge-default-icon-color': state.badge.default.icon, + + '--card-badge-neutral-bg-color': state.badge.neutral.bg, + '--card-badge-neutral-dot-color': state.badge.neutral.dot, + '--card-badge-neutral-fg-color': state.badge.neutral.fg, + '--card-badge-neutral-icon-color': state.badge.neutral.icon, + + '--card-badge-primary-bg-color': state.badge.primary.bg, + '--card-badge-primary-dot-color': state.badge.primary.dot, + '--card-badge-primary-fg-color': state.badge.primary.fg, + '--card-badge-primary-icon-color': state.badge.primary.icon, + + '--card-badge-suggest-bg-color': state.badge.suggest.bg, + '--card-badge-suggest-dot-color': state.badge.suggest.dot, + '--card-badge-suggest-fg-color': state.badge.suggest.fg, + '--card-badge-suggest-icon-color': state.badge.suggest.icon, + + '--card-badge-positive-bg-color': state.badge.positive.bg, + '--card-badge-positive-dot-color': state.badge.positive.dot, + '--card-badge-positive-fg-color': state.badge.positive.fg, + '--card-badge-positive-icon-color': state.badge.positive.icon, + + '--card-badge-caution-bg-color': state.badge.caution.bg, + '--card-badge-caution-dot-color': state.badge.caution.dot, + '--card-badge-caution-fg-color': state.badge.caution.fg, + '--card-badge-caution-icon-color': state.badge.caution.icon, + + '--card-badge-critical-bg-color': state.badge.critical.bg, + '--card-badge-critical-dot-color': state.badge.critical.dot, + '--card-badge-critical-fg-color': state.badge.critical.fg, + '--card-badge-critical-icon-color': state.badge.critical.icon, + + '--card-bg-color': state.bg, + + '--card-border-color': state.border, + + '--card-code-bg-color': state.code.bg, + '--card-code-fg-color': state.code.fg, + + '--card-fg-color': state.fg, + + '--card-icon-color': state.icon, + + '--card-kbd-bg-color': state.kbd.bg, + '--card-kbd-border-color': state.kbd.border, + '--card-kbd-fg-color': state.kbd.fg, + + '--card-link-fg-color': state.link.fg, + + '--card-muted-bg-color': state.muted.bg, + '--card-muted-fg-color': state.muted.fg, + + '--card-skeleton-from-color': state.skeleton.from, + '--card-skeleton-to-color': state.skeleton.to, + + 'backgroundColor': 'var(--card-bg-color)', + 'color': 'var(--card-fg-color)', + } +} diff --git a/dev/storybook/stories/themer/theme/Theme.stories.tsx b/dev/storybook/stories/themer/theme/Theme.stories.tsx new file mode 100644 index 0000000000..f87469098c --- /dev/null +++ b/dev/storybook/stories/themer/theme/Theme.stories.tsx @@ -0,0 +1,108 @@ +import {Box, Card, Tree, TreeItem, useRootTheme} from '@sanity/ui' +import type {Meta, StoryObj} from '@storybook/react-vite' + +import {BuildStory} from './build/BuildStory' +import {CanvasStory} from './CanvasStory' +import {DebugStory} from './DebugStory' + +/** + * The theme that `buildTheme` from `@sanity/themer` generates from the preset picked in the + * toolbar, in both color schemes. + */ +const meta: Meta = { + parameters: {controls: {include: []}}, +} + +export default meta +type Story = StoryObj + +export const Debug: Story = { + parameters: {padding: 0}, + render: () => , +} + +export const Build: Story = { + parameters: {padding: 0}, + render: () => , + // The theme-builder preview renders an enormous DOM which crashes headless + // Chromium when it runs as a browser test + tags: ['!test'], +} + +export const Canvas: Story = { + parameters: {padding: 0}, + render: () => , + // The color canvas preview is too heavy to render within the browser test + // timeout + tags: ['!test'], +} + +function ColorStory() { + const {theme} = useRootTheme() + + // oxlint-disable-next-line no-deprecated + if (!theme.color) { + return null + } + + return ( + + + {/* oxlint-disable-next-line no-deprecated */} + {Object.entries(theme.color).map(([key, value]) => ( + + ))} + + + ) +} + +function ColorGroup({name, value}: {name: string; value: Record}) { + const entries = Object.entries(value) + + return ( + + {entries.map(([key, value]) => { + if (value && typeof value === 'object') { + // oxlint-disable-next-line no-unsafe-type-assertion + return } /> + } + + if (typeof value !== 'string') { + return null + } + + return + })} + + ) +} + +function ColorPreview({name, value}: {name: string; value: string}) { + const text = ( + <> + + {name} {value} + + ) + + return +} + +export const Color: Story = { + render: () => , + // The color tree preview is too heavy to render as a browser test + tags: ['!test'], +} diff --git a/dev/storybook/stories/themer/theme/build/BuildStory.tsx b/dev/storybook/stories/themer/theme/build/BuildStory.tsx new file mode 100644 index 0000000000..d01c0978f7 --- /dev/null +++ b/dev/storybook/stories/themer/theme/build/BuildStory.tsx @@ -0,0 +1,463 @@ +import {rem, useRootTheme, useTheme_v2} from '@sanity/ui' +import { + THEME_COLOR_BUTTON_MODES, + THEME_COLOR_CARD_TONES, + THEME_COLOR_INPUT_MODES, + THEME_COLOR_STATE_TONES, + type ThemeColorButtonTone_v2, + type ThemeColorCard_v2, + type ThemeColorCardToneKey, + type ThemeColorInputMode_v2, + type ThemeColorInputModeKey, + type ThemeColorState_v2, +} from '@sanity/ui/theme' + +import {getCSSProps} from './helpers' +import {Root} from './Root' + +export function BuildStory(): React.JSX.Element { + const {theme} = useRootTheme() + + return ( + +
rem(r)), + // oxlint-disable-next-line no-deprecated + shadows: theme.shadows.map((s) => + s + ? { + umbra: s.umbra.join(' '), + penumbra: s.penumbra.join(' '), + ambient: s.ambient.join(' '), + } + : {}, + ), + // oxlint-disable-next-line no-deprecated + space: theme.space.map((s) => rem(s)), + }), + }} + > +
+ {THEME_COLOR_CARD_TONES.map((baseTone) => ( + + ))} +
+
+ {THEME_COLOR_CARD_TONES.map((baseTone) => ( + + ))} +
+
+
+ ) +} + +function BaseTonePreview(props: { + baseTone: ThemeColorCardToneKey + scheme: 'light' | 'dark' + theme: ThemeColorCard_v2 +}) { + const {baseTone, scheme, theme: color} = props + const theme = useTheme_v2() + + return ( + // oxlint-disable-next-line no-unsafe-type-assertion +
)}> +
+
+          color/{scheme}/{baseTone}
+        
+
+
+ Shadow +
+
+
+ +
+
+ {THEME_COLOR_INPUT_MODES.map((mode) => ( + + ))} +
+
+ {THEME_COLOR_BUTTON_MODES.map((mode) => ( +
+ {THEME_COLOR_STATE_TONES.map((tone) => { + return + })} +
+ ))} +
+
+
+ ) +} + +function StatePreview(props: {theme: ThemeColorState_v2}) { + const {theme: card} = props + + return ( +
+
+
+
Card
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ +
+
+ +
+
+
+
Card
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ +
+
+ +
+
+
+
Card
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ +
+
+ +
+
+
+
+ ) +} + +function ButtonPreview(props: {theme: ThemeColorButtonTone_v2}) { + const {theme: button} = props + + return ( +
+
+
Button
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ k +
+
+ b +
+
+ +
+
+ +
+
+
+
Button
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ +
+
+ +
+
+
+
Button
+
+ © +
+
+ m +
+
+ a +
+
+ l +
+
+ c +
+
+ +
+
+ +
+
+
+ ) +} + +function CheckeredBackground() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} + +function Skeleton() { + return ( +
+
+
+
+ ) +} + +function InputPreview(props: {mode: ThemeColorInputModeKey; theme: ThemeColorInputMode_v2}) { + const {mode, theme: input} = props + + return ( +
+
+ Text ({mode}) Placeholder +
+
+ Text ({mode}) +
+
+ Text ({mode}) +
+
+ ) +} diff --git a/dev/storybook/stories/themer/theme/build/Root.tsx b/dev/storybook/stories/themer/theme/build/Root.tsx new file mode 100644 index 0000000000..43ef815506 --- /dev/null +++ b/dev/storybook/stories/themer/theme/build/Root.tsx @@ -0,0 +1,367 @@ +import {getTheme_v2} from '@sanity/ui/theme' +import {css, styled} from 'styled-components' + +export const Root = styled.div((props) => { + const {button, card, input} = getTheme_v2(props.theme) + + return css` + background: #000; + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; + font-size: 13px; + line-height: 9px; + margin: 0; + -webkit-font-smoothing: antialiased; + user-select: none; + + pre { + font: inherit; + font-family: 'SF Mono', Menlo, monospace; + margin: 0; + padding: 0; + } + + .base { + background-color: var(--bg); + color: var(--fg); + } + + .button { + --border-width: ${button.border.width}px; + + --bg: var(--button-enabled-bg); + --fg: var(--button-enabled-fg); + --icon: var(--button-enabled-icon); + --border: var(--button-enabled-border); + --muted-bg: var(--button-enabled-muted-bg); + --muted-fg: var(--button-enabled-muted-fg); + --accent-fg: var(--button-enabled-accent-fg); + --link-fg: var(--button-enabled-link-fg); + --code-bg: var(--button-enabled-code-bg); + --code-fg: var(--button-enabled-code-fg); + --skeleton-from: var(--button-enabled-skeleton-from); + --skeleton-to: var(--button-enabled-skeleton-to); + + background-color: var(--bg); + color: var(--fg); + box-shadow: inset 0 0 0 var(--border-width) var(--border); + text-align: center; + border-radius: var(--radius-2); + font-weight: 500; + cursor: default; + transition: box-shadow 50ms ease-in-out; + outline: none; + justify-content: center; + align-items: center; + + &:hover { + --bg: var(--button-hovered-bg); + --fg: var(--button-hovered-fg); + --icon: var(--button-hovered-icon); + --border: var(--button-hovered-border); + --muted-bg: var(--button-hovered-muted-bg); + --muted-fg: var(--button-hovered-muted-fg); + --accent-fg: var(--button-hovered-accent-fg); + --link-fg: var(--button-hovered-link-fg); + --code-bg: var(--button-hovered-code-bg); + --code-fg: var(--button-hovered-code-fg); + --skeleton-from: var(--button-hovered-skeleton-from); + --skeleton-to: var(--button-hovered-skeleton-to); + } + + &:focus-visible { + box-shadow: + inset 0 0 0 var(--border-width) var(--border), + 0 0 0 1px var(--base-bg), + 0 0 0 3px var(--focus-ring); + + --bg: var(--button-hovered-bg); + --fg: var(--button-hovered-fg); + --icon: var(--button-hovered-icon); + --border: var(--button-hovered-border); + --muted-bg: var(--button-hovered-muted-bg); + --muted-fg: var(--button-hovered-muted-fg); + --accent-fg: var(--button-hovered-accent-fg); + --link-fg: var(--button-hovered-link-fg); + --code-bg: var(--button-hovered-code-bg); + --code-fg: var(--button-hovered-code-fg); + --skeleton-from: var(--button-hovered-skeleton-from); + --skeleton-to: var(--button-hovered-skeleton-to); + } + + &:active { + --bg: var(--button-pressed-bg); + --fg: var(--button-pressed-fg); + --icon: var(--button-pressed-icon); + --border: var(--button-pressed-border); + --muted-bg: var(--button-pressed-muted-bg); + --muted-fg: var(--button-pressed-muted-fg); + --accent-fg: var(--button-pressed-accent-fg); + --link-fg: var(--button-pressed-link-fg); + --code-bg: var(--button-pressed-code-bg); + --code-fg: var(--button-pressed-code-fg); + --skeleton-from: var(--button-pressed-skeleton-from); + --skeleton-to: var(--button-pressed-skeleton-to); + } + + &.selected { + --bg: var(--button-selected-bg); + --fg: var(--button-selected-fg); + --icon: var(--button-selected-icon); + --border: var(--button-selected-border); + --muted-bg: var(--button-selected-muted-bg); + --muted-fg: var(--button-selected-muted-fg); + --accent-fg: var(--button-selected-accent-fg); + --link-fg: var(--button-selected-link-fg); + --code-bg: var(--button-selected-code-bg); + --code-fg: var(--button-selected-code-fg); + --skeleton-from: var(--button-selected-skeleton-from); + --skeleton-to: var(--button-selected-skeleton-to); + } + + &.disabled { + --bg: var(--button-disabled-bg); + --fg: var(--button-disabled-fg); + --icon: var(--button-disabled-icon); + --border: var(--button-disabled-border); + --muted-bg: var(--button-disabled-muted-bg); + --muted-fg: var(--button-disabled-muted-fg); + --accent-fg: var(--button-disabled-accent-fg); + --link-fg: var(--button-disabled-link-fg); + --code-bg: var(--button-disabled-code-bg); + --code-fg: var(--button-disabled-code-fg); + --skeleton-from: var(--button-disabled-skeleton-from); + --skeleton-to: var(--button-disabled-skeleton-to); + } + } + + .card { + --border-width: ${card.border.width}px; + + --bg: var(--card-enabled-bg); + --fg: var(--card-enabled-fg); + --icon: var(--card-enabled-icon); + --border: var(--card-enabled-border); + --muted-bg: var(--card-enabled-muted-bg); + --muted-fg: var(--card-enabled-muted-fg); + --accent-fg: var(--card-enabled-accent-fg); + --link-fg: var(--card-enabled-link-fg); + --code-bg: var(--card-enabled-code-bg); + --code-fg: var(--card-enabled-code-fg); + --kbd-bg: var(--card-enabled-kbd-bg); + --skeleton-from: var(--card-enabled-skeleton-from); + --skeleton-to: var(--card-enabled-skeleton-to); + + background-color: var(--bg); + color: var(--fg); + box-shadow: inset 0 0 0 var(--border-width) var(--border); + border-radius: var(--radius-2); + font-weight: 500; + cursor: default; + transition: box-shadow 50ms ease-in-out; + outline: none; + + &:hover { + --bg: var(--card-hovered-bg); + --fg: var(--card-hovered-fg); + --icon: var(--card-hovered-icon); + --border: var(--card-hovered-border); + --muted-bg: var(--card-hovered-muted-bg); + --muted-fg: var(--card-hovered-muted-fg); + --accent-fg: var(--card-hovered-accent-fg); + --link-fg: var(--card-hovered-link-fg); + --code-bg: var(--card-hovered-code-bg); + --code-fg: var(--card-hovered-code-fg); + --skeleton-from: var(--card-hovered-skeleton-from); + --skeleton-to: var(--card-hovered-skeleton-to); + } + + &:focus-visible { + box-shadow: + inset 0 0 0 var(--border-width) var(--border), + 0 0 0 1px var(--base-bg), + 0 0 0 3px var(--focus-ring); + + /* --bg: var(--card-hovered-bg); + --fg: var(--card-hovered-fg); + --icon: var(--card-hovered-icon); + --border: var(--card-hovered-border); + --muted-bg: var(--card-hovered-muted-bg); + --muted-fg: var(--card-hovered-muted-fg); + --accent-fg: var(--card-hovered-accent-fg); + --link-fg: var(--card-hovered-link-fg); + --code-bg: var(--card-hovered-code-bg); + --code-fg: var(--card-hovered-code-fg); + --skeleton-from: var(--card-hovered-skeleton-from); + --skeleton-to: var(--card-hovered-skeleton-to); */ + } + + &:active { + --bg: var(--card-pressed-bg); + --fg: var(--card-pressed-fg); + --icon: var(--card-pressed-icon); + --border: var(--card-pressed-border); + --muted-bg: var(--card-pressed-muted-bg); + --muted-fg: var(--card-pressed-muted-fg); + --accent-fg: var(--card-pressed-accent-fg); + --link-fg: var(--card-pressed-link-fg); + --code-bg: var(--card-pressed-code-bg); + --code-fg: var(--card-pressed-code-fg); + --skeleton-from: var(--card-pressed-skeleton-from); + --skeleton-to: var(--card-pressed-skeleton-to); + } + + &.selected { + --bg: var(--card-selected-bg); + --fg: var(--card-selected-fg); + --icon: var(--card-selected-icon); + --border: var(--card-selected-border); + --muted-bg: var(--card-selected-muted-bg); + --muted-fg: var(--card-selected-muted-fg); + --accent-fg: var(--card-selected-accent-fg); + --link-fg: var(--card-selected-link-fg); + --code-bg: var(--card-selected-code-bg); + --code-fg: var(--card-selected-code-fg); + --skeleton-from: var(--card-selected-skeleton-from); + --skeleton-to: var(--card-selected-skeleton-to); + } + + &.disabled { + --bg: var(--card-disabled-bg); + --fg: var(--card-disabled-fg); + --icon: var(--card-disabled-icon); + --border: var(--card-disabled-border); + --muted-bg: var(--card-disabled-muted-bg); + --muted-fg: var(--card-disabled-muted-fg); + --accent-fg: var(--card-disabled-accent-fg); + --link-fg: var(--card-disabled-link-fg); + --code-bg: var(--card-disabled-code-bg); + --code-fg: var(--card-disabled-code-fg); + --skeleton-from: var(--card-disabled-skeleton-from); + --skeleton-to: var(--card-disabled-skeleton-to); + } + } + + .input { + background-color: var(--bg); + color: var(--fg); + padding: var(--space-3); + box-shadow: inset 0 0 0 ${input.border.width}px var(--border); + font-size: 16px; + line-height: 11px; + border-radius: var(--radius-2); + cursor: default; + transition: box-shadow 50ms ease-in-out; + + .placeholder { + color: var(--placeholder); + } + + --bg: var(--input-enabled-bg); + --bg2: var(--input-enabled-bg2); + --fg: var(--input-enabled-fg); + --border: var(--input-enabled-border); + --placeholder: var(--input-enabled-placeholder); + + &:hover { + --bg: var(--input-hovered-bg); + --bg2: var(--input-hovered-bg2); + --fg: var(--input-hovered-fg); + --border: var(--input-hovered-border); + --placeholder: var(--input-hovered-placeholder); + } + + &:focus, + &.readOnly:focus { + outline: none; + box-shadow: inset 0 0 0 1px var(--focus-ring); + + --bg: var(--input-hovered-bg); + --bg2: var(--input-hovered-bg2); + --fg: var(--input-hovered-fg); + --border: var(--input-hovered-border); + --placeholder: var(--input-hovered-placeholder); + } + + &.readOnly { + --bg: var(--input-read-only-bg); + --bg2: var(--input-read-only-bg2); + --fg: var(--input-read-only-fg); + --border: var(--input-read-only-border); + --placeholder: var(--input-read-only-placeholder); + } + + &.disabled { + --bg: var(--input-disabled-bg); + --bg2: var(--input-disabled-bg2); + --fg: var(--input-disabled-fg); + --border: var(--input-disabled-border); + --placeholder: var(--input-disabled-placeholder); + } + } + + .bg2 { + background-color: var(--muted-bg); + } + + .border { + border: 1px solid var(--border); + } + + .icon { + color: var(--icon); + } + + .muted { + color: var(--muted-fg); + } + + .accent { + color: var(--accent-fg); + } + + .link { + color: var(--link-fg); + } + + .code { + background-color: var(--code-bg); + color: var(--code-fg); + } + + .kbd { + background-color: var(--kbd-bg); + color: var(--kbd-fg); + } + + .badge { + background-color: var(--badge-bg); + color: var(--badge-fg); + } + + .badge--default { + --badge-bg: var(--badge-default-bg); + --badge-fg: var(--badge-default-fg); + --badge-border: var(--badge-default-border); + } + + .skeleton-from { + color: var(--skeleton-from); + } + + .skeleton-to { + color: var(--skeleton-to); + } + ` +}) diff --git a/dev/storybook/stories/themer/theme/build/helpers.ts b/dev/storybook/stories/themer/theme/build/helpers.ts new file mode 100644 index 0000000000..664edad80d --- /dev/null +++ b/dev/storybook/stories/themer/theme/build/helpers.ts @@ -0,0 +1,48 @@ +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +export function getCSSProps(value: Record): Record { + const entries = getCSSPropsEntries(value) + + return Object.fromEntries( + entries.map((entry) => { + return [`--${entry.path.map(camelCaseToSnakeCase).join('-')}`, String(entry.value)] + }), + ) +} + +interface CSSPropEntry { + path: string[] + value: unknown +} + +function getCSSPropsEntries(value: Record, path: string[] = []): CSSPropEntry[] { + const entries: CSSPropEntry[] = [] + + for (const [key, val] of Object.entries(value)) { + if (key.startsWith('_')) continue + + if (isRecord(val)) { + entries.push(...getCSSPropsEntries(val, [...path, key])) + } else if (Array.isArray(val)) { + entries.push( + ...val.map((d, idx) => { + if (isRecord(d)) { + entries.push(...getCSSPropsEntries(d, [...path, key, String(idx)])) + } + + return {path: [...path, key, String(idx)], value: String(d)} + }), + ) + } else { + entries.push({path: [...path, key], value: val}) + } + } + + return entries +} + +function camelCaseToSnakeCase(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) +} diff --git a/dev/storybook/stories/themer/tokens/ColorPalette.tsx b/dev/storybook/stories/themer/tokens/ColorPalette.tsx new file mode 100644 index 0000000000..5f58794c08 --- /dev/null +++ b/dev/storybook/stories/themer/tokens/ColorPalette.tsx @@ -0,0 +1,171 @@ +import {COLOR_HUES, COLOR_TINTS, type ColorHueKey} from '@sanity/color' +import {buildPalette, type BuildThemeOptions, type GeneratedColorPalette} from '@sanity/themer' +import {Box, Card, Flex, Grid, Heading, Stack, Text, useRootTheme} from '@sanity/ui' +import {Code} from '@sanity/ui/code' +import {getContrastRatio, hexToRgb, rgbToHsl} from '@sanity/ui/theme' +import {ToastProvider, useToast} from '@sanity/ui/toast' +import type {ReactNode} from 'react' +import {styled} from 'styled-components' + +import {AA_CONTRAST_THRESHOLD, AAA_CONTRAST_THRESHOLD} from './contrast' + +function ucfirst(str: string) { + return str.slice(0, 1).toUpperCase() + str.slice(1) +} + +/** + * The palette that `buildPalette` from `@sanity/themer` generates from the options for the color + * scheme the story renders in: `black` and `white` are the scheme's backgrounds, `blue` the accent + * scale and `gray` the text scale. + */ +export function ColorPalette(props: {options: BuildThemeOptions}): ReactNode { + const {options} = props + const {scheme} = useRootTheme() + const palette = buildPalette(options)[scheme] + + return ( + + + + + {COLOR_HUES.map((hueKey) => ( + + ))} + + + + ) +} + +interface Tint { + title: string + hex: string +} + +function getTints(palette: GeneratedColorPalette, hueKey: ColorHueKey): Tint[] { + return COLOR_TINTS.map((tintKey) => ({ + title: `${hueKey} ${tintKey}`, + hex: palette[hueKey][tintKey], + })) +} + +function ColorHuePreview(props: {hueKey: string; palette: GeneratedColorPalette; tints: Tint[]}) { + const {hueKey, palette, tints} = props + + return ( + + + {ucfirst(hueKey)} + + + + {tints.map((tint) => ( + + ))} + + + ) +} + +const ColorCard = styled(Card)<{$bg: string; $fg: string}>` + cursor: pointer; + + --card-bg-color: ${({$bg}) => $bg}; + --card-fg-color: ${({$fg}) => $fg}; + + &:not(:disabled):active, + &:not(:disabled):hover { + --card-bg-color: ${({$bg}) => $bg} !important; + --card-fg-color: ${({$fg}) => $fg} !important; + } +` + +/** AA/AAA contrast badge, shown when the tint passes WCAG AA against the given scheme */ +function ContrastBadge(props: {contrast: number; scheme: 'dark' | 'light'}) { + const {contrast, scheme} = props + + if (contrast < AA_CONTRAST_THRESHOLD) return null + + return ( + + + {contrast >= AAA_CONTRAST_THRESHOLD ? 'AAA' : 'AA'} · {contrast.toFixed(1)}:1 + + + ) +} + +function ColorTintPreview(props: {palette: GeneratedColorPalette; tint: Tint}) { + const {palette, tint} = props + const hsl = rgbToHsl(hexToRgb(tint.hex)) + const toast = useToast() + + // Against the backgrounds of the generated palette rather than stock black and white + const contrast = { + dark: getContrastRatio(tint.hex, palette.black), + light: getContrastRatio(tint.hex, palette.white), + } + + const handleClick = () => { + navigator.clipboard.writeText(tint.hex).then( + () => + toast.push({ + title: ( + <> + Copied {tint.title} ({tint.hex}) to clipboard + + ), + }), + () => toast.push({status: 'error', title: 'Could not write to clipboard'}), + ) + } + + return ( + + + + + {tint.title} + + + + {(contrast.dark >= AA_CONTRAST_THRESHOLD || contrast.light >= AA_CONTRAST_THRESHOLD) && ( + + + + + )} + + + + {tint.hex} + + + + + ) +} diff --git a/dev/storybook/stories/themer/tokens/Palette.stories.tsx b/dev/storybook/stories/themer/tokens/Palette.stories.tsx new file mode 100644 index 0000000000..3b8475c4a6 --- /dev/null +++ b/dev/storybook/stories/themer/tokens/Palette.stories.tsx @@ -0,0 +1,49 @@ +import {presets} from '@sanity/themer' +import type {Meta, StoryObj} from '@storybook/react-vite' + +import {ColorPalette} from './ColorPalette' + +interface PaletteArgs { + accent?: string + text?: string + background?: string + contrast?: number +} + +/** + * The `@sanity/color`-shaped palette that `buildPalette` from `@sanity/themer` generates, in the + * color scheme picked in the toolbar. + */ +const meta: Meta = { + parameters: {controls: {include: []}, padding: 0}, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +/** The palette of the preset picked in the toolbar */ +export const Default: Story = { + render: (_args, {globals}) => { + const preset = presets.find((candidate) => candidate.slug === globals['preset']) + + return + }, +} + +/** A palette generated from the colors in the controls, for both schemes */ +export const Custom: Story = { + args: { + accent: '#1cb485', + text: '#5c9199', + contrast: 85, + }, + argTypes: { + accent: {control: 'color'}, + text: {control: 'color'}, + background: {control: 'color'}, + contrast: {control: {type: 'range', min: 15, max: 100, step: 1}}, + }, + parameters: {controls: {include: ['accent', 'text', 'background', 'contrast']}}, + render: (args) => , +} diff --git a/dev/storybook/stories/themer/tokens/contrast.ts b/dev/storybook/stories/themer/tokens/contrast.ts new file mode 100644 index 0000000000..913bf3274a --- /dev/null +++ b/dev/storybook/stories/themer/tokens/contrast.ts @@ -0,0 +1,8 @@ +/** + * WCAG 2.x contrast-ratio thresholds + * (https://www.w3.org/TR/WCAG22/#contrast-minimum), shared by the color token + * stories. The ratios compared against them are computed elsewhere, with + * `getContrastRatio` from `@sanity/ui/theme`. + */ +export const AA_CONTRAST_THRESHOLD = 4.5 +export const AAA_CONTRAST_THRESHOLD = 7 diff --git a/dev/storybook/stories/themer/tool/AnimatedColorWheelIcon.stories.tsx b/dev/storybook/stories/themer/tool/AnimatedColorWheelIcon.stories.tsx new file mode 100644 index 0000000000..0d48c98ae6 --- /dev/null +++ b/dev/storybook/stories/themer/tool/AnimatedColorWheelIcon.stories.tsx @@ -0,0 +1,74 @@ +import {Button, Flex, Text} from '@sanity/ui' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {animate, useMotionValue} from 'motion/react' +import {useEffect} from 'react' + +import {AnimatedColorWheelIcon} from '../../../../../plugins/@sanity/themer/src/tool/AnimatedColorWheelIcon' +import {ANIMATION_DURATION} from '../../../../../plugins/@sanity/themer/src/tool/colorWheel' + +interface WheelArgs { + progress: number +} + +/** + * The color wheel of the themer tool's navbar toggle, which introduces the tool: the slices pop + * in with color one at a time, the wheel spins once, and the colors pop out again. + */ +const meta: Meta = { + parameters: {controls: {include: []}}, +} + +export default meta +type Story = StoryObj + +function NavbarButton() { + const progress = useMotionValue(0) + + const play = () => { + // A run in progress plays out, like on the Studio navbar + if (progress.isAnimating()) return + + animate(progress, [0, 1], {duration: ANIMATION_DURATION, ease: 'linear'}) + } + + return ( + + + ))} +
+ + + + + ) : ( + + + 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)} + /> + ))} + + + + + + + + +