diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml new file mode 100644 index 0000000..eacc8c7 --- /dev/null +++ b/.github/workflows/visual-review.yml @@ -0,0 +1,231 @@ +name: Visual review + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + +# Per-PR group so label-accept and capture do not cancel each other incorrectly. +concurrency: + group: ${{ contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && format('visual-label-pr-{0}', github.event.pull_request.number) || format('visual-review-pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + # Removes hosted report when the PR closes (InkAds-style pr-preview cleanup). + preview-cleanup: + name: preview-cleanup + if: > + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Remove PR visual preview + uses: rossjrw/pr-preview-action@v1.8.1 + with: + source-dir: . + preview-branch: gh-pages + pages-base-url: https://singleton-sd.github.io/post-kit + umbrella-dir: pr-preview + action: remove + comment: false + + visual-review: + name: visual-review + if: > + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Detect visual-accepted label + id: accept + uses: actions/github-script@v7 + with: + script: | + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const accepted = labels.some((l) => l.name === "visual-accepted"); + core.setOutput("accepted", accepted ? "true" : "false"); + if (accepted) { + core.info("Label visual-accepted is present; gate will pass."); + } + + - name: Accept intentional visual diffs + if: steps.accept.outputs.accepted == 'true' + run: | + echo "visual-accepted label present — clearing visual-review (no rebuild required)." + + - uses: actions/checkout@v4 + if: steps.accept.outputs.accepted != 'true' + + - uses: pnpm/action-setup@v4 + if: steps.accept.outputs.accepted != 'true' + with: + version: 9.15.0 + + - uses: actions/setup-node@v4 + if: steps.accept.outputs.accepted != 'true' + with: + node-version: 24 + cache: pnpm + + - name: Install + if: steps.accept.outputs.accepted != 'true' + run: pnpm install --frozen-lockfile + + - name: Build editor package deps + if: steps.accept.outputs.accepted != 'true' + run: | + pnpm --filter @singleton-sd/post-kit-types run build + pnpm --filter @singleton-sd/post-kit-compiler run build + pnpm --filter @singleton-sd/post-kit-editor run build + + - name: Build Storybook + if: steps.accept.outputs.accepted != 'true' + run: pnpm --filter @singleton-sd/post-kit-editor build-storybook + + - name: Install Playwright Chromium + if: steps.accept.outputs.accepted != 'true' + run: pnpm --filter @singleton-sd/post-kit-editor exec playwright install chromium --with-deps + + - name: Capture visual screenshots + id: capture + if: steps.accept.outputs.accepted != 'true' + run: pnpm --filter @singleton-sd/post-kit-editor test:visual + + - name: Upload visual artifact + if: steps.accept.outputs.accepted != 'true' && always() && steps.capture.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: editor-visual + path: packages/post-kit-editor/test-results/visual + if-no-files-found: ignore + retention-days: 14 + + - name: Stage visual report for GitHub Pages + id: stage + if: steps.accept.outputs.accepted != 'true' && always() && steps.capture.outcome == 'success' + run: | + set -euo pipefail + rm -rf visual-pages + mkdir -p visual-pages/visual + cp -R packages/post-kit-editor/test-results/visual/. visual-pages/visual/ + # Root of the PR preview redirects to /visual/ + printf '%s\n' \ + '' \ + '' \ + '' \ + 'PostKit visual report' \ + '

Open visual report

' \ + > visual-pages/index.html + touch visual-pages/.nojekyll + echo "visual_url=https://singleton-sd.github.io/post-kit/pr-preview/pr-${{ github.event.number }}/visual/" >> "$GITHUB_OUTPUT" + + - name: Deploy visual report to GitHub Pages + id: deploy + if: steps.accept.outputs.accepted != 'true' && always() && steps.stage.outcome == 'success' + continue-on-error: true + uses: rossjrw/pr-preview-action@v1.8.1 + with: + source-dir: visual-pages + preview-branch: gh-pages + pages-base-url: https://singleton-sd.github.io/post-kit + umbrella-dir: pr-preview + comment: false + + - name: Retry deploy visual report + id: deploy_retry + if: steps.accept.outputs.accepted != 'true' && always() && steps.deploy.outcome == 'failure' + uses: rossjrw/pr-preview-action@v1.8.1 + with: + source-dir: visual-pages + preview-branch: gh-pages + pages-base-url: https://singleton-sd.github.io/post-kit + umbrella-dir: pr-preview + comment: false + + - name: Comment visual review link on PR + if: steps.accept.outputs.accepted != 'true' && always() && steps.capture.outcome == 'success' + uses: actions/github-script@v7 + env: + VISUAL_URL: ${{ steps.stage.outputs.visual_url }} + with: + script: | + const fs = require('fs'); + const marker = ''; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const artifactUrl = `${runUrl}#artifacts`; + const visualUrl = + process.env.VISUAL_URL || + `https://singleton-sd.github.io/post-kit/pr-preview/pr-${context.issue.number}/visual/`; + const manifestPath = + 'packages/post-kit-editor/test-results/visual/manifest.json'; + let summaryLine = '_Capture finished._'; + let statusLine = ''; + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const s = manifest.summary || {}; + const changed = (s.changedStories || []).join(', ') || '—'; + const neu = (s.newStories || []).join(', ') || '—'; + summaryLine = [ + `**${s.unchanged ?? 0}** unchanged · **${s.changed ?? 0}** changed · **${s.new ?? 0}** new`, + s.changed ? `Changed: \`${changed}\`` : null, + s.new ? `New: \`${neu}\`` : null, + ] + .filter(Boolean) + .join('\n'); + statusLine = s.hasDiffs + ? '⚠️ Visual review needed — open the live report, then add `visual-accepted` (or update baselines).' + : '✅ Visual review clear vs committed baselines.'; + } + const body = [ + marker, + '## Visual review (Storybook)', + '', + `🖼️ **[Open live visual report](${visualUrl})** (baseline · PR · diff)`, + '', + statusLine, + '', + summaryLine, + '', + `- Artifact backup: [\`editor-visual\`](${artifactUrl})`, + `- Workflow run: ${runUrl}`, + '', + 'To accept intentional diffs: update `packages/post-kit-editor/visual-baselines/` from CI Linux `pr/*.png`, or add the **`visual-accepted`** label.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + # Gate last so the live report is deployed even when review is required. + - name: Visual gate + if: steps.accept.outputs.accepted != 'true' + env: + VISUAL_ACCEPTED: '0' + run: pnpm --filter @singleton-sd/post-kit-editor test:visual:gate diff --git a/.gitignore b/.gitignore index 7404ac3..b40e94d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ coverage/ storybook-static/ +packages/post-kit-editor/test-results/ # Marketplace skills from singleton-sd/ai-plattform-skills (`pnpm sync:skills`). # Do not commit copies; another repo owns the skill source. diff --git a/docs/guides/editor-integration.md b/docs/guides/editor-integration.md index 2b2dc17..e0171ee 100644 --- a/docs/guides/editor-integration.md +++ b/docs/guides/editor-integration.md @@ -1,22 +1,27 @@ # Editor integration How a consumer embeds [`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor/README.md) -in a React admin application so an internal user can edit EmailBuilder.js -template sources, preview them, and persist changes through -**consumer-supplied** callbacks that ultimately feed the Git-backed publish -pipeline. +so an internal user can list and edit EmailBuilder.js template sources, preview +them, and persist changes through **consumer-supplied** callbacks that feed the +Git-backed publish pipeline. + +**Default path:** mount one component — `EmailTemplateAdmin` — which owns the +template list, MUI EmailBuilder.js canvas (inspector + samples, comparable to +[the official playground](https://usewaypoint.github.io/email-builder-js)), and +PostKit metadata / variables / preview / validation / save / send chrome. Template file layout and variables: [`template-authoring.md`](./template-authoring.md). Publishing to Blob: [`template-publishing.md`](./template-publishing.md). -A runnable minimal host lives in +A runnable thin host lives in [`examples/admin-editor/`](../../examples/admin-editor/). -## What the editor is (and is not) +## What the package is (and is not) -`@singleton-sd/post-kit-editor` is a React **authoring surface** for the three -Git-backed source files under `content/email-templates//`: +`@singleton-sd/post-kit-editor` ships a React **admin page** (and lower-level +surfaces) for the three Git-backed source files under +`content/email-templates//`: | File | Role | | --- | --- | @@ -24,8 +29,8 @@ Git-backed source files under `content/email-templates//`: | `metadata.json` | key, name, subject, variables, schemaVersion | | `preview.json` | synthetic sample values for in-editor preview | -It holds those files **in memory**, offers canvas / metadata / preview-data / -validation UI, and calls consumer callbacks on Save (and optional Send-test). +It holds those files **in memory** and calls consumer callbacks on Save (and +optional Send-test). It does **not**: @@ -33,6 +38,7 @@ It does **not**: - accept API keys, tokens, or other credentials as props - write to disk, Git, Azure Blob Storage, or the network - run the publish pipeline (`post-kit-publish` / compiler content hashing) +- own consumer SSO/RBAC (your admin route still wraps the page) Persistence and test delivery leave the package only through the props you pass in. Production secrets stay in Azure Key Vault **`ssd-postkit-kv-prod-ae`** @@ -46,6 +52,10 @@ pnpm add @singleton-sd/post-kit-editor pnpm add react@^18.3.1 react-dom@^18.3.1 ``` +MUI / Emotion are **runtime dependencies** of the package. You do **not** need +to install or wrap `ThemeProvider` yourself — `EmailTemplateAdmin` (and the +canvas surface) provide them. + | Requirement | As shipped | | --- | --- | | React / React DOM | **Peer** `^18.3.1` — the host owns the React instance | @@ -55,34 +65,33 @@ pnpm add react@^18.3.1 react-dom@^18.3.1 Cross-package install notes: [`packages.md`](./packages.md). -## Minimal embedding +## Recommended embedding: `EmailTemplateAdmin` -Load the three source files (from your API, static imports, etc.), validate -them with `loadTemplateSource`, and mount `EmailTemplateEditor`: +Load a catalog of templates (from your API, static imports, etc.), validate each +with `loadTemplateSource`, and mount the full page: ```tsx import { - EmailTemplateEditor, + EmailTemplateAdmin, loadTemplateSource, type SerializedTemplateSource, type TemplateSourceFiles, } from '@singleton-sd/post-kit-editor'; -const template = loadTemplateSource({ - templateJson /* from template.json */, - metadata /* from metadata.json */, - previewData /* from preview.json */, -}); +const catalog: TemplateSourceFiles[] = [ + loadTemplateSource({ + templateJson /* from template.json */, + metadata /* from metadata.json */, + previewData /* from preview.json */, + }), + // … +]; export function TemplateAdminPage() { return ( - { - // Persist via your trusted server — see Persistence below. const res = await fetch('/api/templates', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -92,19 +101,36 @@ export function TemplateAdminPage() { return { ok: false, message: 'Save failed.' }; } }} + onSendTest={async (_serialized, files, recipient) => { + const res = await fetch('/api/email-templates/send-test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + templateKey: files.metadata.key, + to: recipient, + variables: files.previewData, + }), + }); + if (!res.ok) { + return { ok: false, message: 'Test send failed.' }; + } + }} /> ); } ``` -While the host is still fetching files, pass `loading` or `loadError` so the -editor shows a non-interactive shell instead of a blank canvas. +Omit `onSendTest` to hide Send-test chrome. Pass `loading` / `loadError` while +the host is still fetching the catalog (same shells as the single-template +editor). ### Public exports used for integration | Export | Purpose | | --- | --- | -| `EmailTemplateEditor` | Main React component | +| `EmailTemplateAdmin` | **Primary** full-page React component (list + canvas + chrome) | +| `EmailTemplateEditor` | Advanced: single-template surface without list chrome | +| `EmailBuilderCanvas` | Advanced: canvas-only | | `loadTemplateSource` | Validate and normalize the working triple | | `serializeTemplateSource` | Stable Git file strings (also used internally on Save) | | `validateTemplate` / `hasValidationErrors` | Optional host-side validation | @@ -113,19 +139,29 @@ editor shows a non-interactive shell instead of a blank canvas. There is **no** exported `PersistenceAdapter` interface. Persistence is the `onSave` prop (and optionally `onSendTest`). -### Props that matter for embedding +### `EmailTemplateAdmin` props | Prop | Required | Role | | --- | --- | --- | -| `template` | yes | Seeded `TemplateSourceFiles` | +| `templates` | yes | Catalog of `TemplateSourceFiles` (at least one) | | `onSave` | yes | `(serialized, files) => SaveResult \| void \| Promise<…>` | | `onSendTest` | no | When set, shows Send-test chrome; must hit **your** server | -| `availableVariables` | no | Catalogue entries for the editing user | -| `onDirtyChange` | no | Navigation guards while dirty | -| `onValidationChange` | no | Mirror validation in host chrome | -| `onPreviewRendered` | no | Successful preview HTML string | +| `availableVariables` | no | Catalogue labels; defaults to metadata variable names | +| `onDirtyChange` / `onValidationChange` / `onPreviewRendered` | no | Same as `EmailTemplateEditor` | | `loading` / `loadError` | no | Host fetch shells | -| `className` | no | Extra class on the root (`pk-editor-` prefix for CSS) | +| `className` | no | Extra class on the admin root (`pk-admin-` prefix) | + +### Advanced: `EmailTemplateEditor` only + +If you already own list UI and only need the single-template surface: + +```tsx + +``` Full props table: package [README](../../packages/post-kit-editor/README.md). @@ -214,10 +250,10 @@ call `@singleton-sd/post-kit-client` with secrets from Azure Key Vault `ssd-postkit-kv-prod-ae` (or local uncommitted `.env` from `.env.example`). Omitting `onSendTest` hides Send-test chrome entirely. -## Lifecycle: editor → PR → Blob → send +## Lifecycle: admin → PR → Blob → send ```text -Admin embeds EmailTemplateEditor +Admin embeds EmailTemplateAdmin │ onSave → consumer persistence ▼ content/email-templates// (consumer repo) @@ -236,7 +272,7 @@ PostKit API loads Blob at send time ## Access control -- The editor is an **internal admin** surface. Your admin app owns +- The admin page is an **internal** surface. Your host app owns authentication and authorisation (who may open which templates, who may save, who may trigger Send-test). - Do **not** put a long-lived PostKit send credential (or diff --git a/examples/admin-editor/App.tsx b/examples/admin-editor/App.tsx index a14fbbc..112a07f 100644 --- a/examples/admin-editor/App.tsx +++ b/examples/admin-editor/App.tsx @@ -1,26 +1,25 @@ /** * Reference React host for `@singleton-sd/post-kit-editor`. * - * - List/load: seed from the filesystem store in Node tests / server wiring; - * this component takes a preloaded catalog for the embedding demo. - * - Save: `onSave` → your API → Git/PR (here: in-memory adapter for the demo). - * - Send-test: pass `sendTest` only when your BFF is configured; omitting it - * hides Send-test chrome (see `postSendTestToBff` + README). + * Mounts {@link EmailTemplateAdmin} — the full admin page (list, MUI + * EmailBuilder canvas, PostKit chrome). This example only supplies the catalog + * and persistence / send-test callbacks. * - * Never pass API keys into this module or the editor props. + * Never pass API keys into this module or the admin props. */ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { - EmailTemplateEditor, + EmailTemplateAdmin, type SerializedTemplateSource, type TemplateSourceFiles, type SendTestResult, + type SaveResult, } from '@singleton-sd/post-kit-editor'; import { createMemoryPersistence, - reconcileSelectedKey, - toOnSave, + buildWorkingCatalog, + toOnSaveWithRefresh, type MemoryPersistence, } from './src/memory-persistence'; @@ -74,7 +73,6 @@ export function AdminEditorExample({ templates, sendTest }: AdminEditorExamplePr throw new Error('AdminEditorExample requires at least one template.'); } - const catalogKeys = templates.map((t) => t.metadata.key); const persistenceRef = useRef(null); if (persistenceRef.current === null) { const seed: Record = {}; @@ -87,42 +85,26 @@ export function AdminEditorExample({ templates, sendTest }: AdminEditorExamplePr } const persistence = persistenceRef.current; - const [selectedKey, setSelectedKey] = useState(() => catalogKeys[0]!); - - useEffect(() => { - setSelectedKey((current) => reconcileSelectedKey(current, catalogKeys)); - }, [catalogKeys.join('\0')]); + // Bump after successful save so workingCatalog is recomputed from persistence. + const [catalogEpoch, setCatalogEpoch] = useState(0); + const onSave = useCallback( + ( + serialized: SerializedTemplateSource, + files: TemplateSourceFiles, + ): Promise | SaveResult | void => + toOnSaveWithRefresh(persistence, () => setCatalogEpoch((n) => n + 1))(serialized, files), + [persistence], + ); - const effectiveKey = reconcileSelectedKey(selectedKey, catalogKeys); - const template = persistence.load(effectiveKey); - const availableVariables = template.metadata.variables.map((name) => ({ - name, - label: name, - })); + // Prefer in-memory copies (includes successful saves) while keeping prop order. + void catalogEpoch; + const workingCatalog = buildWorkingCatalog(templates, persistence); return ( -
- - -
+ ); } diff --git a/examples/admin-editor/README.md b/examples/admin-editor/README.md index 40386cb..cf92465 100644 --- a/examples/admin-editor/README.md +++ b/examples/admin-editor/README.md @@ -1,10 +1,11 @@ # Example: admin editor + Send-test BFF Reference host for embedding -[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor) in a -**consumer** admin app (InkAds back-office, etc.). PostKit does not host an -admin CMS — your app owns list/load/save auth and Git; this example shows the -wiring. +[`EmailTemplateAdmin`](../../packages/post-kit-editor) from +`@singleton-sd/post-kit-editor` in a **consumer** admin app (InkAds +back-office, etc.). PostKit does not host an admin CMS — your app owns +SSO/RBAC and Git; this example shows the thin wiring around the full page +component. Guide: [`docs/guides/editor-integration.md`](../../docs/guides/editor-integration.md). @@ -12,7 +13,8 @@ Guide: [`docs/guides/editor-integration.md`](../../docs/guides/editor-integratio ```text Admin browser - → EmailTemplateEditor (onSave / onSendTest callbacks only) + → EmailTemplateAdmin (list + MUI EmailBuilder canvas + PostKit chrome) + callbacks: onSave / optional onSendTest only → Your admin API (session / SSO — not PostKit API keys) ├─ list/load/save → content/email-templates/… (or open a PR) └─ POST …/send-test → PostKitClient + POSTKIT_API_KEY → PostKit API @@ -28,7 +30,7 @@ Never put `POSTKIT_API_KEY` in a browser bundle. | --- | --- | | List / load / save on disk | `src/template-store.ts` + seeded `content/email-templates/` | | Save stub + PR reminder | `src/save-stub.ts` | -| React host (list select + editor) | `App.tsx` (in-memory save for the UI demo) | +| React host (`EmailTemplateAdmin`) | `App.tsx` (in-memory save for the UI demo) | | Send-test BFF handler | `src/send-test-handler.ts` | | Env → `PostKitClient` | `src/create-client-from-env.ts` | | In-memory adapter (UI contract) | `src/memory-persistence.ts` | @@ -79,14 +81,17 @@ When the BFF is configured, pass `sendTest={postSendTestToBff}` into ## Map to InkAds (or any) admin -1. Embed `EmailTemplateEditor` on an authenticated admin route (your SSO/RBAC). -2. List/load from your Git tree or admin API (`createFsTemplateStore` pattern). +1. Embed `EmailTemplateAdmin` on an authenticated admin route (your SSO/RBAC). +2. Load the catalog from your Git tree or admin API (`createFsTemplateStore` pattern). 3. `onSave` → trusted server → commit or GitHub App PR (this example writes locally and logs a PR reminder via `toFsOnSave`). 4. `onSendTest` → your BFF → `handleSendTest` + `createPostKitClientFromEnv`. 5. Publish CI: adapt [`docs/examples/publish-email-templates.yml`](../../docs/examples/publish-email-templates.yml). 6. Per-environment keys so Send-test hits the right Blob prefix. +You do **not** rebuild list UI, EmailBuilder inspector/samples, or PostKit +metadata chrome — that is all inside `EmailTemplateAdmin`. + ## Layout ```text diff --git a/examples/admin-editor/src/memory-persistence.spec.ts b/examples/admin-editor/src/memory-persistence.spec.ts index 91c1c86..128f9c2 100644 --- a/examples/admin-editor/src/memory-persistence.spec.ts +++ b/examples/admin-editor/src/memory-persistence.spec.ts @@ -7,7 +7,13 @@ import { type TemplateSourceFiles, } from '@singleton-sd/post-kit-editor'; -import { createMemoryPersistence, reconcileSelectedKey, toOnSave } from './memory-persistence'; +import { + createMemoryPersistence, + reconcileSelectedKey, + toOnSave, + buildWorkingCatalog, + toOnSaveWithRefresh, +} from './memory-persistence'; import templateJson from '../sample/template.json'; import metadata from '../sample/metadata.json'; @@ -118,3 +124,51 @@ describe('reconcileSelectedKey', () => { ); }); }); + +describe('workingCatalog refresh after save', () => { + it('buildWorkingCatalog reflects persistence after a successful save', async () => { + const persistence = createMemoryPersistence({ + [seed.metadata.key]: seed, + [other.metadata.key]: other, + }); + const catalog = [seed, other]; + + const before = buildWorkingCatalog(catalog, persistence); + assert.equal(before[0]!.metadata.name, 'Welcome'); + + let refreshCount = 0; + const onSave = toOnSaveWithRefresh(persistence, () => { + refreshCount += 1; + }); + + const edited: TemplateSourceFiles = { + ...seed, + metadata: { ...seed.metadata, name: 'Welcome (saved)' }, + }; + const result = await onSave(serializeTemplateSource(edited), edited); + assert.deepEqual(result, { ok: true }); + assert.equal(refreshCount, 1); + + const after = buildWorkingCatalog(catalog, persistence); + assert.equal(after[0]!.metadata.name, 'Welcome (saved)'); + assert.equal(after[1]!.metadata.key, other.metadata.key); + }); + + it('does not refresh on failed save', async () => { + const persistence = createMemoryPersistence( + { [seed.metadata.key]: seed }, + { failNextSave: true }, + ); + let refreshCount = 0; + const onSave = toOnSaveWithRefresh(persistence, () => { + refreshCount += 1; + }); + const edited: TemplateSourceFiles = { + ...seed, + metadata: { ...seed.metadata, name: 'Nope' }, + }; + const result = await onSave(serializeTemplateSource(edited), edited); + assert.equal(result && 'ok' in result && result.ok, false); + assert.equal(refreshCount, 0); + }); +}); diff --git a/examples/admin-editor/src/memory-persistence.ts b/examples/admin-editor/src/memory-persistence.ts index 256d1bc..429bbae 100644 --- a/examples/admin-editor/src/memory-persistence.ts +++ b/examples/admin-editor/src/memory-persistence.ts @@ -128,3 +128,34 @@ export function toOnSave( ) => Promise | SaveResult | void { return (serialized, files) => persistence.save(serialized, files); } + +/** Map prop catalog to in-memory copies (including prior successful saves). */ +export function buildWorkingCatalog( + templates: TemplateSourceFiles[], + persistence: MemoryPersistence, +): TemplateSourceFiles[] { + return templates.map((t) => + persistence.has(t.metadata.key) ? persistence.load(t.metadata.key) : t, + ); +} + +/** + * Like {@link toOnSave}, but invokes `onRefresh` after a successful write so + * React hosts can recompute {@link buildWorkingCatalog}. + */ +export function toOnSaveWithRefresh( + persistence: MemoryPersistence, + onRefresh: () => void, +): ( + serialized: SerializedTemplateSource, + files: TemplateSourceFiles, +) => Promise | SaveResult | void { + return async (serialized, files) => { + const result = await Promise.resolve(persistence.save(serialized, files)); + const failed = result && typeof result === 'object' && 'ok' in result && result.ok === false; + if (!failed) { + onRefresh(); + } + return result; + }; +} diff --git a/packages/post-kit-editor/.storybook/main.ts b/packages/post-kit-editor/.storybook/main.ts index b8a14a7..4db325e 100644 --- a/packages/post-kit-editor/.storybook/main.ts +++ b/packages/post-kit-editor/.storybook/main.ts @@ -11,7 +11,7 @@ const packagesRoot = join(packageRoot, '..'); */ const config: StorybookConfig = { stories: ['../stories/**/*.stories.@(ts|tsx)'], - addons: [], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], framework: { name: '@storybook/react-vite', options: {}, diff --git a/packages/post-kit-editor/.storybook/preview.ts b/packages/post-kit-editor/.storybook/preview.ts index a50a087..77df82c 100644 --- a/packages/post-kit-editor/.storybook/preview.ts +++ b/packages/post-kit-editor/.storybook/preview.ts @@ -1,5 +1,7 @@ import type { Preview } from '@storybook/react'; +import '../stories/storybook-shell.css'; + const preview: Preview = { parameters: { layout: 'padded', @@ -9,6 +11,33 @@ const preview: Preview = { date: /Date$/i, }, }, + backgrounds: { + default: 'postkit', + values: [ + { name: 'postkit', value: '#f2f5f7' }, + { name: 'white', value: '#ffffff' }, + ], + }, + viewport: { + viewports: { + desktop: { + name: 'Desktop', + styles: { width: '1440px', height: '900px' }, + type: 'desktop', + }, + narrow: { + name: 'Narrow', + styles: { width: '900px', height: '800px' }, + type: 'desktop', + }, + mobile: { + name: 'Mobile', + styles: { width: '390px', height: '844px' }, + type: 'mobile', + }, + }, + defaultViewport: 'desktop', + }, }, }; diff --git a/packages/post-kit-editor/README.md b/packages/post-kit-editor/README.md index 6efe05c..4db737c 100644 --- a/packages/post-kit-editor/README.md +++ b/packages/post-kit-editor/README.md @@ -1,11 +1,15 @@ # @singleton-sd/post-kit-editor -React admin editor component for [PostKit](../../README.md) email templates. It -edits the three Git-backed source files (`template.json`, `metadata.json`, +Full-page React admin for [PostKit](../../README.md) email templates +(`EmailTemplateAdmin`), plus lower-level surfaces for advanced hosts. Edits the +three Git-backed source files (`template.json`, `metadata.json`, `preview.json`) **in memory**, with Save and optional Send-test controls that call **consumer-supplied** callbacks. The package never writes to disk, Git, or the network, and never accepts API keys or other credentials as props. +The canvas is a port of the official EmailBuilder.js MUI sample (inspector + +samples). See [`src/email-builder-ui/NOTICE.md`](./src/email-builder-ui/NOTICE.md). + ## Installation ```bash @@ -18,78 +22,70 @@ React is a **peer dependency** — the consumer application owns the React insta pnpm add react@^18.3.1 react-dom@^18.3.1 ``` -## Usage +MUI / Emotion ship as package dependencies. You do not need to wrap +`ThemeProvider` yourself. + +## Usage (recommended) ```tsx import { - EmailTemplateEditor, + EmailTemplateAdmin, type TemplateSourceFiles, type SerializedTemplateSource, - type ValidationIssue, } from '@singleton-sd/post-kit-editor'; -export function TemplateAdminPage({ - template, - loading, - loadError, -}: { - template: TemplateSourceFiles; - loading?: boolean; - loadError?: string; -}) { +export function TemplateAdminPage({ templates }: { templates: TemplateSourceFiles[] }) { return ( - { - // Commit serialized.templateJson / metadataJson / previewJson to the - // consumer repository (e.g. via the app's own server endpoint). const res = await fetch('/api/templates', { method: 'PUT', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serialized, key: files.metadata.key }), }); - // fetch() resolves for HTTP 4xx/5xx — return failure so the editor - // keeps dirty state and does not treat the rejection as success. if (!res.ok) { return { ok: false, message: 'Save failed.' }; } }} - onSendTest={async (serialized, _files, recipient) => { - // Browser → your trusted server only. The server uses - // @singleton-sd/post-kit-client with secrets from Azure Key Vault - // (production) or local `.env` (development). Never embed a - // long-lived PostKit API key in browser code. - const res = await fetch('/api/templates/send-test', { + onSendTest={async (_serialized, files, recipient) => { + const res = await fetch('/api/email-templates/send-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serialized, recipient }), + body: JSON.stringify({ + templateKey: files.metadata.key, + to: recipient, + variables: files.previewData, + }), }); if (!res.ok) { return { ok: false, message: 'Test send failed.' }; } }} - onDirtyChange={(dirty) => { - // Optional: guard in-app navigation while dirty. - }} - onValidationChange={(issues: ValidationIssue[]) => { - // Optional: mirror validation in the host chrome. - }} - onPreviewRendered={(html) => { - console.log('preview bytes', html.length); - }} - className="tenant-theme" /> ); } ``` -A copy-pasteable single-file integration (plus synthetic sample JSON) lives in -[`examples/minimal/`](./examples/minimal/). +Guide: [`docs/guides/editor-integration.md`](../../docs/guides/editor-integration.md). +Thin host example: [`examples/admin-editor/`](../../examples/admin-editor/). +Single-template advanced surface: `EmailTemplateEditor` (see +[`examples/minimal/`](./examples/minimal/)). ## Props +### `EmailTemplateAdmin` + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `templates` | `TemplateSourceFiles[]` | yes | Catalog (at least one). | +| `onSave` | `(serialized, files) => SaveResult \| void \| Promise<…>` | yes | Persist working files. | +| `onSendTest` | `(serialized, files, recipient) => SendTestResult \| void \| Promise<…>` | no | When set, shows Send-test chrome. | +| `availableVariables` | `TemplateVariable[]` | no | Catalogue labels; defaults to metadata names. | +| `loading` / `loadError` / `className` / dirty & validation callbacks | — | no | Same semantics as `EmailTemplateEditor`. | + +### `EmailTemplateEditor` (advanced) + | Prop | Type | Required | Description | | --- | --- | --- | --- | | `template` | `TemplateSourceFiles` | yes | Seeded working triple (`templateJson`, `metadata`, `previewData`). | @@ -104,8 +100,8 @@ A copy-pasteable single-file integration (plus synthetic sample JSON) lives in | `className` | `string` | no | Extra class on the root element. | Also exported: `loadTemplateSource`, `serializeTemplateSource`, -`validateTemplate`, `hasValidationErrors`, `EDITOR_CLASS_PREFIX`, and related -types. +`validateTemplate`, `hasValidationErrors`, `EDITOR_CLASS_PREFIX`, +`ADMIN_CLASS_PREFIX`, and related types. ## What this package does not do @@ -203,11 +199,10 @@ markup. ## Storybook (local visual exploration) -Package-local Storybook (Vite + React) for clicking through the canvas, -metadata, variable catalogue, preview pane, and save/send-test chrome with -**synthetic fixtures only**. It is not a substitute for -[`examples/minimal/`](./examples/minimal/) (canonical consumer sample) or for -the SSR unit tests above. +Package-local Storybook 8.6 (Vite + React + interactions) for the Admin Email +Builder with **synthetic fixtures only** (no network image URLs — fixtures use +`data:image/svg+xml` URIs). It is not a substitute for +[`examples/minimal/`](./examples/minimal/) or for the SSR unit tests above. ```bash # from the monorepo root @@ -215,12 +210,85 @@ pnpm --filter @singleton-sd/post-kit-editor storybook ``` Opens on http://localhost:6006. Stories live under `stories/` with config in -`.storybook/`. Both are outside the published `files` / `dist` surface (along -with `examples/`). There is no Chromatic / screenshot CI in v1. +`.storybook/`. Both are outside the published `files` / `dist` surface. + +Hierarchy (titles): + +| Group | Focus | +| --- | --- | +| **Admin/Email Builder/Overview** | Empty catalog entry, full welcome catalog | +| **Admin/Email Builder/Blocks/\*** | Per-block `EmailBuilderCanvas` (Text, Heading, Button, Image, Avatar, Divider, Spacer, Container, Columns, Html) | +| **Admin/Email Builder/Layout** | Typography, colours, nested containers, columns, long content | +| **Admin/Email Builder/Templates** | Welcome, OTP, password reset, transactional, receipt, report | +| **Admin/Email Builder/States** | Loading, load error, saving/saved/save error, invalid document, unsaved | +| **Admin/Email Builder/Interactions** | Play functions (`@storybook/test`) for samples + Save success/failure | +| **Admin/Email Builder/Responsive** | Desktop / mobile viewport parameters | + +Reusable fixtures: `stories/fixtures/` (`documents`, `template-sources`, `mocks`). +Shell layout CSS: `stories/storybook-shell.css` (sidebar/canvas grid). Prefer +**Overview / Templates** for full-page Admin review; block stories mount +`EmailBuilderCanvas` fullscreen. + +## Visual review (Playwright + Storybook) + +CI job **`visual-review`** screenshots three Storybook iframe stories against +committed PNGs in [`visual-baselines/`](./visual-baselines/). Capture always +exits 0; `test:visual:gate` fails when the manifest has `changed` / `new` +unless `VISUAL_ACCEPTED=1` (or the PR has label `visual-accepted`). + +The same run **deploys the HTML report to GitHub Pages** (InkAds-style) and +posts a sticky PR comment with a live link: + +`https://singleton-sd.github.io/post-kit/pr-preview/pr-/visual/` + +(`base/` · `pr/` · `diff/` · `index.html`). Closing the PR removes that +preview. Artifact `editor-visual` remains as a backup. + +Stories (desktop 1440×900): + +- `admin-email-builder-overview--full-admin` +- `admin-email-builder-blocks-text--default` +- `admin-email-builder-templates--welcome-editor` + +```bash +# from the monorepo root +pnpm --filter @singleton-sd/post-kit-editor playwright:install +pnpm --filter @singleton-sd/post-kit-editor build-storybook +pnpm --filter @singleton-sd/post-kit-editor test:visual +pnpm --filter @singleton-sd/post-kit-editor test:visual:gate +``` + +Output lands in `test-results/visual/` (gitignored): `pr/`, `base/`, `diff/`, +`manifest.json`, and `index.html`. Open the HTML report locally to inspect +diffs. + +### Updating baselines + +After intentional UI changes, prefer **CI Linux** screenshots (download the +`editor-visual` artifact `pr/*.png`) so local/WSL font rendering does not +drift the gate: + +```bash +cp /path/to/editor-visual/pr/*.png \ + packages/post-kit-editor/visual-baselines/ +``` + +Or from a local Linux capture after `build-storybook` + `test:visual`: + +```bash +cp packages/post-kit-editor/test-results/visual/pr/*.png \ + packages/post-kit-editor/visual-baselines/ +pnpm --filter @singleton-sd/post-kit-editor test:visual +pnpm --filter @singleton-sd/post-kit-editor test:visual:gate +``` + +Commit the updated PNGs under `visual-baselines/`. + +### Accepting diffs without updating baselines -Most stories use the public API (`EmailTemplateEditor`, `EmailBuilderCanvas`). -Isolated panel stories import private modules from `src/` and document that -they are **dev-only** — do not treat those paths as a supported public API. +On a PR, add the GitHub label **`visual-accepted`**. That alone clears the +`visual-review` check (no rebuild). Prefer committing new baselines when the +change is intentional and should become the new reference. ## Development diff --git a/packages/post-kit-editor/package.json b/packages/post-kit-editor/package.json index 778219e..c7a3f6b 100644 --- a/packages/post-kit-editor/package.json +++ b/packages/post-kit-editor/package.json @@ -37,12 +37,19 @@ "scripts": { "build": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.json", "lint": "echo \"lint:editor — covered by root eslint on staged files\"", - "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.spec.json && node --import tsx --test \"src/**/*.spec.ts\" \"src/**/*.spec.tsx\"", + "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.spec.json && node --import tsx --test \"src/**/*.spec.ts\" \"src/**/*.spec.tsx\" \"tests/*.spec.mjs\"", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "playwright:install": "playwright install chromium", + "test:visual": "node tests/visual.mjs", + "test:visual:gate": "node tests/visual-gate.mjs", "prepublishOnly": "pnpm run build" }, "dependencies": { + "@emotion/react": "11.11.3", + "@emotion/styled": "11.11.0", + "@mui/icons-material": "5.15.10", + "@mui/material": "5.15.10", "@singleton-sd/post-kit-compiler": "workspace:*", "@singleton-sd/post-kit-types": "workspace:*", "@usewaypoint/block-avatar": "^0.0.3", @@ -57,19 +64,29 @@ "@usewaypoint/block-text": "^0.0.7", "@usewaypoint/document-core": "^0.0.6", "@usewaypoint/email-builder": "0.0.9", - "zod": "^3.25.76" + "highlight.js": "11.9.0", + "prettier": "3.2.5", + "react-colorful": "5.6.1", + "zod": "^3.25.76", + "zustand": "4.5.1" }, "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@storybook/addon-essentials": "8.6.14", + "@storybook/addon-interactions": "8.6.14", "@storybook/blocks": "8.6.14", "@storybook/react": "8.6.14", "@storybook/react-vite": "8.6.14", + "@storybook/test": "8.6.14", "@types/node": "^20.17.9", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", + "pixelmatch": "^7.1.0", + "playwright": "^1.55.0", + "pngjs": "^7.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "storybook": "8.6.14", diff --git a/packages/post-kit-editor/src/admin/EmailTemplateAdmin.spec.tsx b/packages/post-kit-editor/src/admin/EmailTemplateAdmin.spec.tsx new file mode 100644 index 0000000..93c33e8 --- /dev/null +++ b/packages/post-kit-editor/src/admin/EmailTemplateAdmin.spec.tsx @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { loadTemplateSource } from '../serialization'; +import { ADMIN_CLASS_PREFIX, EmailTemplateAdmin } from './EmailTemplateAdmin'; + +import nestedTemplate from '../__fixtures__/nested-blocks/template.json'; +import nestedMetadata from '../__fixtures__/nested-blocks/metadata.json'; +import nestedPreview from '../__fixtures__/nested-blocks/preview.json'; +import minimalTemplate from '../__fixtures__/minimal/template.json'; +import minimalMetadata from '../__fixtures__/minimal/metadata.json'; +import minimalPreview from '../__fixtures__/minimal/preview.json'; + +const nested = loadTemplateSource({ + templateJson: nestedTemplate, + metadata: nestedMetadata, + previewData: nestedPreview, +}); + +const minimal = loadTemplateSource({ + templateJson: minimalTemplate, + metadata: minimalMetadata, + previewData: minimalPreview, +}); + +describe('EmailTemplateAdmin', () => { + it('renders list chrome, ThemeProvider tree, and MUI canvas surface', () => { + const html = renderToStaticMarkup( + undefined} />, + ); + assert.match(html, new RegExp(`data-testid="${ADMIN_CLASS_PREFIX}root"`)); + assert.match(html, new RegExp(`data-testid="${ADMIN_CLASS_PREFIX}template-select"`)); + assert.match(html, /Email templates/); + assert.match(html, /pk-editor-eb-mui-surface/); + assert.match(html, />Samples { + const without = renderToStaticMarkup( + undefined} />, + ); + assert.doesNotMatch(without, /Send test|Send-test/i); + + const withSend = renderToStaticMarkup( + undefined} + onSendTest={() => undefined} + />, + ); + assert.match(withSend, /Send.test/i); + }); + + it('throws when templates is empty and not loading/loadError', () => { + assert.throws( + () => renderToStaticMarkup( undefined} />), + /at least one template/, + ); + }); + + it('allows empty catalog while loading and renders loading shell', () => { + const html = renderToStaticMarkup( + undefined} />, + ); + assert.match(html, new RegExp(`data-testid="${ADMIN_CLASS_PREFIX}root"`)); + assert.match(html, /pk-editor-loading/); + assert.doesNotMatch(html, new RegExp(`data-testid="${ADMIN_CLASS_PREFIX}template-select"`)); + }); + + it('allows empty catalog with loadError and renders error shell', () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + assert.match(html, new RegExp(`data-testid="${ADMIN_CLASS_PREFIX}root"`)); + assert.match(html, /Synthetic catalog fetch failure/); + }); + + it('throws on duplicate metadata.key when not loading', () => { + const dup = loadTemplateSource({ + templateJson: minimalTemplate, + metadata: { ...minimalMetadata, key: nested.metadata.key }, + previewData: minimalPreview, + }); + assert.throws( + () => + renderToStaticMarkup( + undefined} />, + ), + /duplicate metadata\.key/, + ); + }); + + it('skips duplicate-key check while loading', () => { + const dup = loadTemplateSource({ + templateJson: minimalTemplate, + metadata: { ...minimalMetadata, key: nested.metadata.key }, + previewData: minimalPreview, + }); + const html = renderToStaticMarkup( + undefined} />, + ); + assert.match(html, /pk-editor-loading/); + }); +}); diff --git a/packages/post-kit-editor/src/admin/EmailTemplateAdmin.tsx b/packages/post-kit-editor/src/admin/EmailTemplateAdmin.tsx new file mode 100644 index 0000000..cdc481a --- /dev/null +++ b/packages/post-kit-editor/src/admin/EmailTemplateAdmin.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { + Box, + FormControl, + InputLabel, + ListSubheader, + MenuItem, + ScopedCssBaseline, + Select, + Stack, + ThemeProvider, + Typography, +} from '@mui/material'; + +import { + EDITOR_CLASS_PREFIX, + EmailTemplateEditor, + type EmailTemplateEditorProps, +} from '../email-template-editor'; +import theme from '../email-builder-ui/theme'; +import { loadTemplateSource } from '../serialization'; +import type { TemplateSourceFiles, TemplateVariable } from '../types'; +import { reconcileSelectedKey } from './reconcile-selected-key'; + +export const ADMIN_CLASS_PREFIX = 'pk-admin-'; + +/** Synthetic placeholder used only while catalog is empty under loading/loadError. */ +const EMPTY_CATALOG_PLACEHOLDER: TemplateSourceFiles = loadTemplateSource({ + templateJson: { + root: { + type: 'EmailLayout', + data: { + backdropColor: '#F5F5F5', + canvasColor: '#FFFFFF', + textColor: '#262626', + fontFamily: 'MODERN_SANS', + childrenIds: [], + }, + }, + }, + metadata: { + key: 'admin.placeholder', + name: 'Loading', + subject: 'Loading', + variables: [], + schemaVersion: '1', + }, + previewData: {}, +}); + +export interface EmailTemplateAdminProps extends Omit< + EmailTemplateEditorProps, + 'template' | 'availableVariables' | 'className' +> { + /** + * Catalog of templates the admin may open (from Git / consumer list API). + * Must contain at least one entry unless `loading` or `loadError` is set. + */ + templates: TemplateSourceFiles[]; + /** + * Optional variable catalogue labels. When omitted, declared metadata + * variable names are shown as both name and label. + */ + availableVariables?: TemplateVariable[]; + className?: string; +} + +function assertUniqueCatalogKeys(templates: TemplateSourceFiles[]): void { + const seen = new Set(); + for (const t of templates) { + const key = t.metadata.key; + if (seen.has(key)) { + throw new Error(`EmailTemplateAdmin: duplicate metadata.key "${key}".`); + } + seen.add(key); + } +} + +/** + * Full-page embeddable admin for PostKit email templates. + * + * Owns template list selection, MUI theming, the EmailBuilder.js canvas + * (inspector + samples), and PostKit metadata / preview / save / send chrome. + * Consumers supply the catalog and persistence callbacks only. + */ +export function EmailTemplateAdmin(props: EmailTemplateAdminProps): JSX.Element { + const { templates, loading, loadError } = props; + + if (!loading) { + assertUniqueCatalogKeys(templates); + } + + if (templates.length === 0) { + if (loading || loadError) { + return ; + } + throw new Error('EmailTemplateAdmin requires at least one template.'); + } + + return ; +} + +/** Loading / error shell when the catalog has not arrived yet. */ +function EmailTemplateAdminShell({ + templates: _templates, + availableVariables, + className, + loading, + loadError, + ...editorProps +}: EmailTemplateAdminProps): JSX.Element { + void _templates; + const rootClass = [`${ADMIN_CLASS_PREFIX}root`, className].filter(Boolean).join(' '); + + return ( + + + + + Email templates + + + + + + + + + ); +} + +function EmailTemplateAdminCatalog(props: EmailTemplateAdminProps): JSX.Element { + const { templates, availableVariables, className, ...editorProps } = props; + + const catalogKeys = templates.map((t) => t.metadata.key); + const catalogKeysSignature = catalogKeys.join('\0'); + const templatesByKey = useMemo(() => { + const map = new Map(); + for (const t of templates) { + map.set(t.metadata.key, t); + } + return map; + }, [templates]); + + const [selectedKey, setSelectedKey] = useState(() => catalogKeys[0]!); + + useEffect(() => { + const keys = catalogKeysSignature.length > 0 ? catalogKeysSignature.split('\0') : catalogKeys; + setSelectedKey((current) => reconcileSelectedKey(current, keys)); + }, [catalogKeysSignature, catalogKeys]); + + const effectiveKey = reconcileSelectedKey(selectedKey, catalogKeys); + const template = templatesByKey.get(effectiveKey); + if (!template) { + throw new Error(`EmailTemplateAdmin: missing template for key ${effectiveKey}`); + } + + const resolvedVariables: TemplateVariable[] = + availableVariables ?? + template.metadata.variables.map((name) => ({ + name, + label: name, + })); + + const rootClass = [`${ADMIN_CLASS_PREFIX}root`, className].filter(Boolean).join(' '); + + return ( + + + + + Email templates + + + Template + + + + + + + + + + ); +} diff --git a/packages/post-kit-editor/src/admin/reconcile-selected-key.spec.ts b/packages/post-kit-editor/src/admin/reconcile-selected-key.spec.ts new file mode 100644 index 0000000..d8ab485 --- /dev/null +++ b/packages/post-kit-editor/src/admin/reconcile-selected-key.spec.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { reconcileSelectedKey } from './reconcile-selected-key'; + +describe('reconcileSelectedKey', () => { + it('keeps the current key when it is still in the catalog', () => { + assert.equal(reconcileSelectedKey('a', ['a', 'b']), 'a'); + }); + + it('falls back to the first catalog key when selection is missing', () => { + assert.equal(reconcileSelectedKey('gone', ['a', 'b']), 'a'); + }); + + it('throws when the catalog is empty', () => { + assert.throws(() => reconcileSelectedKey('a', []), /at least one/); + }); +}); diff --git a/packages/post-kit-editor/src/admin/reconcile-selected-key.ts b/packages/post-kit-editor/src/admin/reconcile-selected-key.ts new file mode 100644 index 0000000..39ffe73 --- /dev/null +++ b/packages/post-kit-editor/src/admin/reconcile-selected-key.ts @@ -0,0 +1,10 @@ +/** + * Keep selection on a key that still exists in the catalog. + * Returns `catalogKeys[0]` when the current selection was removed. + */ +export function reconcileSelectedKey(selectedKey: string, catalogKeys: string[]): string { + if (catalogKeys.length === 0) { + throw new Error('Catalog must contain at least one template key.'); + } + return catalogKeys.includes(selectedKey) ? selectedKey : catalogKeys[0]!; +} diff --git a/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx b/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx index e1ea835..3aff088 100644 --- a/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx +++ b/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx @@ -2,9 +2,7 @@ import React from 'react'; import type { EmailBuilderDocument } from '../types'; import { EDITOR_CLASS_PREFIX } from '../email-template-editor'; -import { CanvasEditorProvider } from './editor-context'; -import { EditorBlock } from './EditorBlock'; -import { Reader } from '@usewaypoint/email-builder'; +import { EmailBuilderMuiSurface } from '../email-builder-ui/EmailBuilderMuiSurface'; export interface EmailBuilderCanvasProps { document: EmailBuilderDocument; @@ -17,33 +15,16 @@ export function EmailBuilderCanvas({ onChange, readOnly = false, }: EmailBuilderCanvasProps): JSX.Element { - if (readOnly) { - return ( -
- -
- ); - } - return ( - -
- -
-
+
+ +
); } diff --git a/packages/post-kit-editor/src/canvas/helpers/block-templates.ts b/packages/post-kit-editor/src/canvas/helpers/block-templates.ts index 21dc688..b27ad55 100644 --- a/packages/post-kit-editor/src/canvas/helpers/block-templates.ts +++ b/packages/post-kit-editor/src/canvas/helpers/block-templates.ts @@ -50,7 +50,7 @@ export const BLOCK_TEMPLATES: BlockTemplate[] = [ type: 'Image', data: { props: { - url: 'https://placehold.co/600x400', + url: '', alt: 'Sample image', contentAlignment: 'middle', linkHref: null, diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/index.tsx new file mode 100644 index 0000000..e1be1df --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/index.tsx @@ -0,0 +1,140 @@ +import React from 'react'; + +import { Box, Typography } from '@mui/material'; + +import { TEditorBlock } from '../../../documents/editor/core'; +import { + setDocument, + useDocument, + useSelectedBlockId, +} from '../../../documents/editor/EditorContext'; + +import AvatarSidebarPanel from './input-panels/AvatarSidebarPanel'; +import ButtonSidebarPanel from './input-panels/ButtonSidebarPanel'; +import ColumnsContainerSidebarPanel from './input-panels/ColumnsContainerSidebarPanel'; +import ContainerSidebarPanel from './input-panels/ContainerSidebarPanel'; +import DividerSidebarPanel from './input-panels/DividerSidebarPanel'; +import EmailLayoutSidebarPanel from './input-panels/EmailLayoutSidebarPanel'; +import HeadingSidebarPanel from './input-panels/HeadingSidebarPanel'; +import HtmlSidebarPanel from './input-panels/HtmlSidebarPanel'; +import ImageSidebarPanel from './input-panels/ImageSidebarPanel'; +import SpacerSidebarPanel from './input-panels/SpacerSidebarPanel'; +import TextSidebarPanel from './input-panels/TextSidebarPanel'; + +function renderMessage(val: string) { + return ( + + {val} + + ); +} + +export default function ConfigurationPanel() { + const document = useDocument(); + const selectedBlockId = useSelectedBlockId(); + + if (!selectedBlockId) { + return renderMessage('Click on a block to inspect.'); + } + const block = document[selectedBlockId]; + if (!block) { + return renderMessage( + `Block with id ${selectedBlockId} was not found. Click on a block to reset.`, + ); + } + + const setBlock = (conf: TEditorBlock) => setDocument({ [selectedBlockId]: conf }); + const { data, type } = block; + switch (type) { + case 'Avatar': + return ( + setBlock({ type, data })} + /> + ); + case 'Button': + return ( + setBlock({ type, data })} + /> + ); + case 'ColumnsContainer': + return ( + setBlock({ type, data })} + /> + ); + case 'Container': + return ( + setBlock({ type, data })} + /> + ); + case 'Divider': + return ( + setBlock({ type, data })} + /> + ); + case 'Heading': + return ( + setBlock({ type, data })} + /> + ); + case 'Html': + return ( + setBlock({ type, data })} + /> + ); + case 'Image': + return ( + setBlock({ type, data })} + /> + ); + case 'EmailLayout': + return ( + setBlock({ type, data })} + /> + ); + case 'Spacer': + return ( + setBlock({ type, data })} + /> + ); + case 'Text': + return ( + setBlock({ type, data })} + /> + ); + default: + return
{JSON.stringify(block, null, '  ')}
; + } +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/AvatarSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/AvatarSidebarPanel.tsx new file mode 100644 index 0000000..47d5a77 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/AvatarSidebarPanel.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { AspectRatioOutlined } from '@mui/icons-material'; +import { ToggleButton } from '@mui/material'; +import { AvatarProps, AvatarPropsDefaults, AvatarPropsSchema } from '@usewaypoint/block-avatar'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import RadioGroupInput from './helpers/inputs/RadioGroupInput'; +import SliderInput from './helpers/inputs/SliderInput'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type AvatarSidebarPanelProps = { + data: AvatarProps; + setData: (v: AvatarProps) => void; +}; +export default function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = AvatarPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const size = data.props?.size ?? AvatarPropsDefaults.size; + const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl; + const alt = data.props?.alt ?? AvatarPropsDefaults.alt; + const shape = data.props?.shape ?? AvatarPropsDefaults.shape; + + return ( + + } + units="px" + step={3} + min={32} + max={256} + defaultValue={size} + onChange={(size) => { + updateData({ ...data, props: { ...data.props, size } }); + }} + /> + { + updateData({ ...data, props: { ...data.props, shape } }); + }} + > + Circle + Square + Rounded + + { + updateData({ ...data, props: { ...data.props, imageUrl } }); + }} + /> + { + updateData({ ...data, props: { ...data.props, alt } }); + }} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ButtonSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ButtonSidebarPanel.tsx new file mode 100644 index 0000000..57836af --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ButtonSidebarPanel.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { ToggleButton } from '@mui/material'; +import { ButtonProps, ButtonPropsDefaults, ButtonPropsSchema } from '@usewaypoint/block-button'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import ColorInput from './helpers/inputs/ColorInput'; +import RadioGroupInput from './helpers/inputs/RadioGroupInput'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type ButtonSidebarPanelProps = { + data: ButtonProps; + setData: (v: ButtonProps) => void; +}; +export default function ButtonSidebarPanel({ data, setData }: ButtonSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = ButtonPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const text = data.props?.text ?? ButtonPropsDefaults.text; + const url = data.props?.url ?? ButtonPropsDefaults.url; + const fullWidth = data.props?.fullWidth ?? ButtonPropsDefaults.fullWidth; + const size = data.props?.size ?? ButtonPropsDefaults.size; + const buttonStyle = data.props?.buttonStyle ?? ButtonPropsDefaults.buttonStyle; + const buttonTextColor = data.props?.buttonTextColor ?? ButtonPropsDefaults.buttonTextColor; + const buttonBackgroundColor = + data.props?.buttonBackgroundColor ?? ButtonPropsDefaults.buttonBackgroundColor; + + return ( + + updateData({ ...data, props: { ...data.props, text } })} + /> + updateData({ ...data, props: { ...data.props, url } })} + /> + + updateData({ ...data, props: { ...data.props, fullWidth: v === 'FULL_WIDTH' } }) + } + > + Full + Auto + + updateData({ ...data, props: { ...data.props, size } })} + > + Xs + Sm + Md + Lg + + updateData({ ...data, props: { ...data.props, buttonStyle } })} + > + Rectangle + Rounded + Pill + + + updateData({ ...data, props: { ...data.props, buttonTextColor } }) + } + /> + + updateData({ ...data, props: { ...data.props, buttonBackgroundColor } }) + } + /> + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ColumnsContainerSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ColumnsContainerSidebarPanel.tsx new file mode 100644 index 0000000..f86f2ce --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ColumnsContainerSidebarPanel.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { + SpaceBarOutlined, + VerticalAlignBottomOutlined, + VerticalAlignCenterOutlined, + VerticalAlignTopOutlined, +} from '@mui/icons-material'; +import { ToggleButton } from '@mui/material'; + +import ColumnsContainerPropsSchema, { + ColumnsContainerProps, +} from '../../../../documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import ColumnWidthsInput from './helpers/inputs/ColumnWidthsInput'; +import RadioGroupInput from './helpers/inputs/RadioGroupInput'; +import SliderInput from './helpers/inputs/SliderInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type ColumnsContainerPanelProps = { + data: ColumnsContainerProps; + setData: (v: ColumnsContainerProps) => void; +}; +export default function ColumnsContainerPanel({ data, setData }: ColumnsContainerPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = ColumnsContainerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + updateData({ ...data, props: { ...data.props, columnsCount: v === '2' ? 2 : 3 } }); + }} + > + 2 + 3 + + { + updateData({ ...data, props: { ...data.props, fixedWidths } }); + }} + /> + } + units="px" + step={4} + marks + min={0} + max={80} + defaultValue={data.props?.columnsGap ?? 0} + onChange={(columnsGap) => updateData({ ...data, props: { ...data.props, columnsGap } })} + /> + { + updateData({ ...data, props: { ...data.props, contentAlignment } }); + }} + > + + + + + + + + + + + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx new file mode 100644 index 0000000..eb4750e --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import type { ZodError } from 'zod'; + +import ContainerPropsSchema, { + ContainerProps, +} from '../../../../documents/blocks/Container/ContainerPropsSchema'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type ContainerSidebarPanelProps = { + data: ContainerProps; + setData: (v: ContainerProps) => void; +}; + +export default function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = ContainerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + return ( + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx new file mode 100644 index 0000000..8e4216a --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { HeightOutlined } from '@mui/icons-material'; +import { DividerProps, DividerPropsDefaults, DividerPropsSchema } from '@usewaypoint/block-divider'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import ColorInput from './helpers/inputs/ColorInput'; +import SliderInput from './helpers/inputs/SliderInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type DividerSidebarPanelProps = { + data: DividerProps; + setData: (v: DividerProps) => void; +}; +export default function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = DividerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor; + const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight; + + return ( + + updateData({ ...data, props: { ...data.props, lineColor } })} + /> + } + units="px" + step={1} + min={1} + max={24} + defaultValue={lineHeight} + onChange={(lineHeight) => updateData({ ...data, props: { ...data.props, lineHeight } })} + /> + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/EmailLayoutSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/EmailLayoutSidebarPanel.tsx new file mode 100644 index 0000000..76da155 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/EmailLayoutSidebarPanel.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { RoundedCornerOutlined } from '@mui/icons-material'; + +import EmailLayoutPropsSchema, { + EmailLayoutProps, +} from '../../../../documents/blocks/EmailLayout/EmailLayoutPropsSchema'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import ColorInput, { NullableColorInput } from './helpers/inputs/ColorInput'; +import { NullableFontFamily } from './helpers/inputs/FontFamily'; +import SliderInput from './helpers/inputs/SliderInput'; + +type EmailLayoutSidebarFieldsProps = { + data: EmailLayoutProps; + setData: (v: EmailLayoutProps) => void; +}; +export default function EmailLayoutSidebarFields({ data, setData }: EmailLayoutSidebarFieldsProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = EmailLayoutPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, backdropColor })} + /> + updateData({ ...data, canvasColor })} + /> + updateData({ ...data, borderColor })} + /> + } + units="px" + step={4} + marks + min={0} + max={48} + label="Canvas border radius" + defaultValue={data.borderRadius ?? 0} + onChange={(borderRadius) => updateData({ ...data, borderRadius })} + /> + updateData({ ...data, fontFamily })} + /> + updateData({ ...data, textColor })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx new file mode 100644 index 0000000..2232d93 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { ToggleButton } from '@mui/material'; +import { HeadingProps, HeadingPropsDefaults, HeadingPropsSchema } from '@usewaypoint/block-heading'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import RadioGroupInput from './helpers/inputs/RadioGroupInput'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type HeadingSidebarPanelProps = { + data: HeadingProps; + setData: (v: HeadingProps) => void; +}; +export default function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = HeadingPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + updateData({ ...data, props: { ...data.props, text } }); + }} + /> + { + updateData({ ...data, props: { ...data.props, level } }); + }} + > + H1 + H2 + H3 + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx new file mode 100644 index 0000000..4e74969 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type HtmlSidebarPanelProps = { + data: HtmlProps; + setData: (v: HtmlProps) => void; +}; +export default function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = HtmlPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, props: { ...data.props, contents } })} + /> + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ImageSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ImageSidebarPanel.tsx new file mode 100644 index 0000000..6768951 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/ImageSidebarPanel.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { + VerticalAlignBottomOutlined, + VerticalAlignCenterOutlined, + VerticalAlignTopOutlined, +} from '@mui/icons-material'; +import { Stack, ToggleButton } from '@mui/material'; +import { ImageProps, ImagePropsSchema } from '@usewaypoint/block-image'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import RadioGroupInput from './helpers/inputs/RadioGroupInput'; +import TextDimensionInput from './helpers/inputs/TextDimensionInput'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type ImageSidebarPanelProps = { + data: ImageProps; + setData: (v: ImageProps) => void; +}; +export default function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = ImagePropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + const url = v.trim().length === 0 ? null : v.trim(); + updateData({ ...data, props: { ...data.props, url } }); + }} + /> + + updateData({ ...data, props: { ...data.props, alt } })} + /> + { + const linkHref = v.trim().length === 0 ? null : v.trim(); + updateData({ ...data, props: { ...data.props, linkHref } }); + }} + /> + + updateData({ ...data, props: { ...data.props, width } })} + /> + updateData({ ...data, props: { ...data.props, height } })} + /> + + + + updateData({ ...data, props: { ...data.props, contentAlignment } }) + } + > + + + + + + + + + + + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx new file mode 100644 index 0000000..197a8e3 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { HeightOutlined } from '@mui/icons-material'; +import { SpacerProps, SpacerPropsDefaults, SpacerPropsSchema } from '@usewaypoint/block-spacer'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import SliderInput from './helpers/inputs/SliderInput'; + +type SpacerSidebarPanelProps = { + data: SpacerProps; + setData: (v: SpacerProps) => void; +}; +export default function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = SpacerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + } + units="px" + step={4} + min={4} + max={128} + defaultValue={data.props?.height ?? SpacerPropsDefaults.height} + onChange={(height) => updateData({ ...data, props: { ...data.props, height } })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx new file mode 100644 index 0000000..aa482a1 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { ZodError } from 'zod'; + +import { TextProps, TextPropsSchema } from '@usewaypoint/block-text'; + +import BaseSidebarPanel from './helpers/BaseSidebarPanel'; +import BooleanInput from './helpers/inputs/BooleanInput'; +import TextInput from './helpers/inputs/TextInput'; +import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; + +type TextSidebarPanelProps = { + data: TextProps; + setData: (v: TextProps) => void; +}; +export default function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = TextPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, props: { ...data.props, text } })} + /> + updateData({ ...data, props: { ...data.props, markdown } })} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx new file mode 100644 index 0000000..284b829 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { Box, Stack, Typography } from '@mui/material'; + +type SidebarPanelProps = { + title: string; + children: React.ReactNode; +}; +export default function BaseSidebarPanel({ title, children }: SidebarPanelProps) { + return ( + + + {title} + + + {children} + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx new file mode 100644 index 0000000..427269f --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; + +import { FormControlLabel, Switch } from '@mui/material'; + +type Props = { + label: string; + defaultValue: boolean; + onChange: (value: boolean) => void; +}; + +export default function BooleanInput({ label, defaultValue, onChange }: Props) { + const [value, setValue] = useState(defaultValue); + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + return ( + { + setValue(checked); + onChange(checked); + }} + /> + } + /> + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/BaseColorInput.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/BaseColorInput.tsx new file mode 100644 index 0000000..d44a83b --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/BaseColorInput.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; + +import { AddOutlined, CloseOutlined } from '@mui/icons-material'; +import { ButtonBase, InputLabel, Menu, Stack } from '@mui/material'; + +import Picker from './Picker'; + +const BUTTON_SX = { + border: '1px solid', + borderColor: 'cadet.400', + width: 32, + height: 32, + borderRadius: '4px', + bgcolor: '#FFFFFF', +}; + +type Props = + | { + nullable: true; + label: string; + onChange: (value: string | null) => void; + defaultValue: string | null; + } + | { + nullable: false; + label: string; + onChange: (value: string) => void; + defaultValue: string; + }; +export default function ColorInput({ label, defaultValue, onChange, nullable }: Props) { + const [anchorEl, setAnchorEl] = useState(null); + const [value, setValue] = useState(defaultValue); + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + const handleClickOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const renderResetButton = () => { + if (!nullable) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + return ( + { + setValue(null); + onChange(null); + }} + > + + + ); + }; + + const renderOpenButton = () => { + if (value) { + return ( + + ); + } + return ( + + + + ); + }; + + return ( + + {label} + + {renderOpenButton()} + {renderResetButton()} + + setAnchorEl(null)} + MenuListProps={{ + sx: { height: 'auto', padding: 0 }, + }} + > + { + setValue(v); + onChange(v); + }} + /> + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx new file mode 100644 index 0000000..41ec818 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react'; +import { HexColorInput, HexColorPicker } from 'react-colorful'; + +import { Box, Stack, SxProps } from '@mui/material'; + +import Swatch from './Swatch'; + +const DEFAULT_PRESET_COLORS = [ + '#E11D48', + '#DB2777', + '#C026D3', + '#9333EA', + '#7C3AED', + '#4F46E5', + '#2563EB', + '#0284C7', + '#0891B2', + '#0D9488', + '#059669', + '#16A34A', + '#65A30D', + '#CA8A04', + '#D97706', + '#EA580C', + '#DC2626', + '#FFFFFF', + '#FAFAFA', + '#F5F5F5', + '#E5E5E5', + '#D4D4D4', + '#A3A3A3', + '#737373', + '#525252', + '#404040', + '#262626', + '#171717', + '#0A0A0A', + '#000000', +]; + +const SX: SxProps = { + p: 1, + '.react-colorful__pointer ': { + width: 16, + height: 16, + }, + '.react-colorful__saturation': { + mb: 1, + borderRadius: '4px', + }, + '.react-colorful__last-control': { + borderRadius: '4px', + }, + '.react-colorful__hue-pointer': { + width: '4px', + borderRadius: '4px', + height: 24, + cursor: 'col-resize', + }, + '.react-colorful__saturation-pointer': { + cursor: 'all-scroll', + }, + input: { + padding: 1, + border: '1px solid', + borderColor: 'grey.300', + borderRadius: '4px', + width: '100%', + }, +}; + +type Props = { + value: string; + onChange: (v: string) => void; +}; +export default function Picker({ value, onChange }: Props) { + const [internalValue, setInternalValue] = useState(value); + useEffect(() => { + setInternalValue(value); + }, [value]); + const handleChange = (v: string) => { + setInternalValue(v); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + onChange(v); + } + }; + + return ( + + + + + + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx new file mode 100644 index 0000000..52b8c96 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { Box, Button, SxProps } from '@mui/material'; + +type Props = { + paletteColors: string[]; + value: string; + onChange: (value: string) => void; +}; + +const TILE_BUTTON: SxProps = { + width: 24, + height: 24, +}; +export default function Swatch({ paletteColors, value, onChange }: Props) { + const renderButton = (colorValue: string) => { + const selected = value === colorValue; + return ( + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/ToggleSamplesPanelButton.tsx b/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/ToggleSamplesPanelButton.tsx new file mode 100644 index 0000000..54a1f5e --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/ToggleSamplesPanelButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { FirstPageOutlined, MenuOutlined } from '@mui/icons-material'; +import { IconButton } from '@mui/material'; + +import { + toggleSamplesDrawerOpen, + useSamplesDrawerOpen, +} from '../../documents/editor/EditorContext'; + +export default function ToggleSamplesPanelButton() { + const samplesDrawerOpen = useSamplesDrawerOpen(); + return ( + + {samplesDrawerOpen ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/index.tsx new file mode 100644 index 0000000..0639c4a --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/SamplesDrawer/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Drawer, Stack, Typography } from '@mui/material'; + +import { useSamplesDrawerOpen } from '../../documents/editor/EditorContext'; + +import SidebarButton from './SidebarButton'; + +export const SAMPLES_DRAWER_WIDTH = 240; + +export default function SamplesDrawer() { + const samplesDrawerOpen = useSamplesDrawerOpen(); + + return ( + + + + + Samples + + + + Empty + Welcome email + + One-time passcode (OTP) + + Reset password + E-commerce receipt + + Subscription receipt + + + Reservation reminder + + Post metrics + + Respond to inquiry + + + + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/DownloadJson/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/DownloadJson/index.tsx new file mode 100644 index 0000000..f891f79 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/DownloadJson/index.tsx @@ -0,0 +1,20 @@ +import React, { useMemo } from 'react'; + +import { FileDownloadOutlined } from '@mui/icons-material'; +import { IconButton, Tooltip } from '@mui/material'; + +import { useDocument } from '../../../documents/editor/EditorContext'; + +export default function DownloadJson() { + const doc = useDocument(); + const href = useMemo(() => { + return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`; + }, [doc]); + return ( + + + + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/HtmlPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/HtmlPanel.tsx new file mode 100644 index 0000000..a0ab352 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/HtmlPanel.tsx @@ -0,0 +1,13 @@ +import React, { useMemo } from 'react'; + +import { renderToStaticMarkup } from '@usewaypoint/email-builder'; + +import { useDocument } from '../../documents/editor/EditorContext'; + +import HighlightedCodePanel from './helper/HighlightedCodePanel'; + +export default function HtmlPanel() { + const document = useDocument(); + const code = useMemo(() => renderToStaticMarkup(document, { rootBlockId: 'root' }), [document]); + return ; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx new file mode 100644 index 0000000..2756a71 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; + +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + TextField, + Typography, +} from '@mui/material'; + +import { resetDocument } from '../../../documents/editor/EditorContext'; + +import validateJsonStringValue from './validateJsonStringValue'; + +type ImportJsonDialogProps = { + onClose: () => void; +}; +export default function ImportJsonDialog({ onClose }: ImportJsonDialogProps) { + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + + const handleChange: React.ChangeEventHandler = (ev) => { + const v = ev.currentTarget.value; + setValue(v); + const { error } = validateJsonStringValue(v); + setError(error ?? null); + }; + + let errorAlert = null; + if (error) { + errorAlert = {error}; + } + + return ( + + Import JSON +
{ + ev.preventDefault(); + const { error, data } = validateJsonStringValue(value); + setError(error ?? null); + if (!data) { + return; + } + resetDocument(data); + onClose(); + }} + > + + + Copy and paste an EmailBuilder.js JSON ( + + example + + ). + + {errorAlert} + + + + + + +
+
+ ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/index.tsx new file mode 100644 index 0000000..afc5f1e --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/index.tsx @@ -0,0 +1,26 @@ +import React, { useState } from 'react'; + +import { FileUploadOutlined } from '@mui/icons-material'; +import { IconButton, Tooltip } from '@mui/material'; + +import ImportJsonDialog from './ImportJsonDialog'; + +export default function ImportJson() { + const [open, setOpen] = useState(false); + + let dialog = null; + if (open) { + dialog = setOpen(false)} />; + } + + return ( + <> + + setOpen(true)}> + + + + {dialog} + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/validateJsonStringValue.ts b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/validateJsonStringValue.ts new file mode 100644 index 0000000..ffa4b32 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/ImportJson/validateJsonStringValue.ts @@ -0,0 +1,24 @@ +import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core'; + +type TResult = + { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined }; + +export default function validateTextAreaValue(value: string): TResult { + let jsonObject = undefined; + try { + jsonObject = JSON.parse(value); + } catch { + return { error: 'Invalid json' }; + } + + const parseResult = EditorConfigurationSchema.safeParse(jsonObject); + if (!parseResult.success) { + return { error: 'Invalid JSON schema' }; + } + + if (!parseResult.data.root) { + return { error: 'Missing "root" node' }; + } + + return { data: parseResult.data }; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/JsonPanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/JsonPanel.tsx new file mode 100644 index 0000000..ce628ec --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/JsonPanel.tsx @@ -0,0 +1,11 @@ +import React, { useMemo } from 'react'; + +import { useDocument } from '../../documents/editor/EditorContext'; + +import HighlightedCodePanel from './helper/HighlightedCodePanel'; + +export default function JsonPanel() { + const document = useDocument(); + const code = useMemo(() => JSON.stringify(document, null, ' '), [document]); + return ; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/MainTabsGroup.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/MainTabsGroup.tsx new file mode 100644 index 0000000..98ea311 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/MainTabsGroup.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { + CodeOutlined, + DataObjectOutlined, + EditOutlined, + PreviewOutlined, +} from '@mui/icons-material'; +import { Tab, Tabs, Tooltip } from '@mui/material'; + +import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext'; + +export default function MainTabsGroup() { + const selectedMainTab = useSelectedMainTab(); + const handleChange = (_: unknown, v: unknown) => { + switch (v) { + case 'json': + case 'preview': + case 'editor': + case 'html': + setSelectedMainTab(v); + return; + default: + setSelectedMainTab('editor'); + } + }; + + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/HighlightedCodePanel.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/HighlightedCodePanel.tsx new file mode 100644 index 0000000..e35d156 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/HighlightedCodePanel.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { html, json } from './highlighters'; + +type TextEditorPanelProps = { + type: 'json' | 'html' | 'javascript'; + value: string; +}; +export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) { + const [code, setCode] = useState(null); + const generationRef = useRef(0); + + useEffect(() => { + const generation = ++generationRef.current; + const apply = (next: string) => { + if (generation === generationRef.current) { + setCode(next); + } + }; + + switch (type) { + case 'html': + html(value).then(apply); + return; + case 'json': + json(value).then(apply); + return; + } + }, [value, type]); + + if (code === null) { + return null; + } + + return ( +
 {
+        const s = window.getSelection();
+        if (s === null) {
+          return;
+        }
+        s.selectAllChildren(ev.currentTarget);
+      }}
+    />
+  );
+}
diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/highlighters.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/highlighters.tsx
new file mode 100644
index 0000000..c53dae8
--- /dev/null
+++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/helper/highlighters.tsx
@@ -0,0 +1,28 @@
+import hljs from 'highlight.js';
+import jsonHighlighter from 'highlight.js/lib/languages/json';
+import xmlHighlighter from 'highlight.js/lib/languages/xml';
+import prettierPluginBabel from 'prettier/plugins/babel';
+import prettierPluginEstree from 'prettier/plugins/estree';
+import prettierPluginHtml from 'prettier/plugins/html';
+import { format } from 'prettier/standalone';
+
+hljs.registerLanguage('json', jsonHighlighter);
+hljs.registerLanguage('html', xmlHighlighter);
+
+export async function html(value: string): Promise {
+  const prettyValue = await format(value, {
+    parser: 'html',
+    plugins: [prettierPluginHtml],
+  });
+  return hljs.highlight(prettyValue, { language: 'html' }).value;
+}
+
+export async function json(value: string): Promise {
+  const prettyValue = await format(value, {
+    parser: 'json',
+    printWidth: 0,
+    trailingComma: 'all',
+    plugins: [prettierPluginBabel, prettierPluginEstree],
+  });
+  return hljs.highlight(prettyValue, { language: 'javascript' }).value;
+}
diff --git a/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/index.tsx
new file mode 100644
index 0000000..426995a
--- /dev/null
+++ b/packages/post-kit-editor/src/email-builder-ui/App/TemplatePanel/index.tsx
@@ -0,0 +1,132 @@
+import React from 'react';
+
+import { MonitorOutlined, PhoneIphoneOutlined } from '@mui/icons-material';
+import { Box, Stack, SxProps, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
+import { Reader } from '@usewaypoint/email-builder';
+
+import EditorBlock from '../../documents/editor/EditorBlock';
+import {
+  setSelectedScreenSize,
+  useDocument,
+  useSelectedMainTab,
+  useSelectedScreenSize,
+} from '../../documents/editor/EditorContext';
+import ToggleInspectorPanelButton from '../InspectorDrawer/ToggleInspectorPanelButton';
+import ToggleSamplesPanelButton from '../SamplesDrawer/ToggleSamplesPanelButton';
+
+import DownloadJson from './DownloadJson';
+import HtmlPanel from './HtmlPanel';
+import ImportJson from './ImportJson';
+import JsonPanel from './JsonPanel';
+import MainTabsGroup from './MainTabsGroup';
+
+export default function TemplatePanel() {
+  const document = useDocument();
+  const selectedMainTab = useSelectedMainTab();
+  const selectedScreenSize = useSelectedScreenSize();
+
+  let mainBoxSx: SxProps = {
+    height: '100%',
+  };
+  if (selectedScreenSize === 'mobile') {
+    mainBoxSx = {
+      ...mainBoxSx,
+      margin: '32px auto',
+      width: 370,
+      height: 800,
+      boxShadow:
+        'rgba(33, 36, 67, 0.04) 0px 10px 20px, rgba(33, 36, 67, 0.04) 0px 2px 6px, rgba(33, 36, 67, 0.04) 0px 0px 1px',
+    };
+  }
+
+  const handleScreenSizeChange = (_: unknown, value: unknown) => {
+    switch (value) {
+      case 'mobile':
+      case 'desktop':
+        setSelectedScreenSize(value);
+        return;
+      default:
+        setSelectedScreenSize('desktop');
+    }
+  };
+
+  const renderMainPanel = () => {
+    switch (selectedMainTab) {
+      case 'editor':
+        return (
+          
+            
+          
+        );
+      case 'preview':
+        return (
+          
+            
+          
+        );
+      case 'html':
+        return ;
+      case 'json':
+        return ;
+    }
+  };
+
+  return (
+    <>
+      
+        
+        
+          
+            
+          
+          
+            
+            
+            
+              
+                
+                  
+                
+              
+              
+                
+                  
+                
+              
+            
+          
+        
+        
+      
+      
+        {renderMainPanel()}
+      
+    
+  );
+}
diff --git a/packages/post-kit-editor/src/email-builder-ui/App/index.tsx b/packages/post-kit-editor/src/email-builder-ui/App/index.tsx
new file mode 100644
index 0000000..1e84150
--- /dev/null
+++ b/packages/post-kit-editor/src/email-builder-ui/App/index.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { Stack, useTheme } from '@mui/material';
+
+import { useInspectorDrawerOpen, useSamplesDrawerOpen } from '../documents/editor/EditorContext';
+
+import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from './InspectorDrawer';
+import SamplesDrawer, { SAMPLES_DRAWER_WIDTH } from './SamplesDrawer';
+import TemplatePanel from './TemplatePanel';
+
+function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right', open: boolean) {
+  const { transitions } = useTheme();
+  return transitions.create(cssProperty, {
+    easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
+    duration: !open ? transitions.duration.leavingScreen : transitions.duration.enteringScreen,
+  });
+}
+
+export default function App() {
+  const inspectorDrawerOpen = useInspectorDrawerOpen();
+  const samplesDrawerOpen = useSamplesDrawerOpen();
+
+  const marginLeftTransition = useDrawerTransition('margin-left', samplesDrawerOpen);
+  const marginRightTransition = useDrawerTransition('margin-right', inspectorDrawerOpen);
+
+  return (
+    <>
+      
+      
+
+      
+        
+      
+    
+  );
+}
diff --git a/packages/post-kit-editor/src/email-builder-ui/EmailBuilderMuiSurface.tsx b/packages/post-kit-editor/src/email-builder-ui/EmailBuilderMuiSurface.tsx
new file mode 100644
index 0000000..50957f6
--- /dev/null
+++ b/packages/post-kit-editor/src/email-builder-ui/EmailBuilderMuiSurface.tsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useRef } from 'react';
+
+import { CssBaseline, ThemeProvider } from '@mui/material';
+import { Reader } from '@usewaypoint/email-builder';
+
+import type { EmailBuilderDocument } from '../types';
+import App from './App';
+import { resetDocument, subscribeDocument } from './documents/editor/EditorContext';
+import type { TEditorConfiguration } from './documents/editor/core';
+import theme from './theme';
+
+export interface EmailBuilderMuiSurfaceProps {
+  document: EmailBuilderDocument;
+  onChange: (document: EmailBuilderDocument) => void;
+  readOnly?: boolean;
+}
+
+function asEditorDocument(document: EmailBuilderDocument): TEditorConfiguration {
+  return document as TEditorConfiguration;
+}
+
+/**
+ * Controlled MUI EmailBuilder.js canvas surface for PostKit.
+ * Syncs the Zustand editor store with parent `document` / `onChange`.
+ *
+ * The first controlled document is applied synchronously during render so SSR /
+ * `renderToStaticMarkup` paints the correct tree; later prop updates sync in
+ * an effect to avoid updating subscribers while React is rendering.
+ */
+export function EmailBuilderMuiSurface({
+  document,
+  onChange,
+  readOnly = false,
+}: EmailBuilderMuiSurfaceProps): JSX.Element {
+  const lastEmittedJsonRef = useRef(null);
+  const hasAppliedInitialRef = useRef(false);
+  const incomingJson = JSON.stringify(document);
+
+  if (!readOnly && !hasAppliedInitialRef.current) {
+    hasAppliedInitialRef.current = true;
+    lastEmittedJsonRef.current = incomingJson;
+    resetDocument(asEditorDocument(document));
+  }
+
+  useEffect(() => {
+    if (readOnly) {
+      return;
+    }
+    if (incomingJson === lastEmittedJsonRef.current) {
+      return;
+    }
+    lastEmittedJsonRef.current = incomingJson;
+    resetDocument(asEditorDocument(document));
+  }, [document, incomingJson, readOnly]);
+
+  useEffect(() => {
+    if (readOnly) {
+      return;
+    }
+    return subscribeDocument((next) => {
+      const json = JSON.stringify(next);
+      if (json === lastEmittedJsonRef.current) {
+        return;
+      }
+      lastEmittedJsonRef.current = json;
+      onChange(next as EmailBuilderDocument);
+    });
+  }, [onChange, readOnly]);
+
+  if (readOnly) {
+    return (
+      
+ + + + +
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/NOTICE.md b/packages/post-kit-editor/src/email-builder-ui/NOTICE.md new file mode 100644 index 0000000..556f475 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/NOTICE.md @@ -0,0 +1,14 @@ +# Notice — EmailBuilder.js MUI sample + +Parts of `email-builder-ui/` are adapted from the +[usewaypoint/email-builder-js](https://github.com/usewaypoint/email-builder-js) +Vite + MUI example (`examples/vite-emailbuilder-mui`), licensed under the MIT +License. + +Copyright (c) Waypoint / usewaypoint contributors. + +Modifications for PostKit: + +- Controlled document sync via `EmailBuilderMuiSurface` (no `window.location.hash`) +- Samples drawer without Waypoint marketing / UTM links +- Share-via-hash removed; Import/Download JSON retained diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx new file mode 100644 index 0000000..2814ea0 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; + +import { useCurrentBlockId } from '../../editor/EditorBlock'; +import { setDocument, setSelectedBlockId } from '../../editor/EditorContext'; +import EditorChildrenIds, { EditorChildrenChange } from '../helpers/EditorChildrenIds'; + +import ColumnsContainerPropsSchema, { ColumnsContainerProps } from './ColumnsContainerPropsSchema'; + +const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }]; + +export default function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) { + const currentBlockId = useCurrentBlockId(); + + const { columns, ...restProps } = props ?? {}; + const columnsValue = columns ?? EMPTY_COLUMNS; + + const updateColumn = ( + columnIndex: 0 | 1 | 2, + { block, blockId, childrenIds }: EditorChildrenChange, + ) => { + const nColumns = [...columnsValue]; + nColumns[columnIndex] = { childrenIds }; + setDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'ColumnsContainer', + data: ColumnsContainerPropsSchema.parse({ + style, + props: { + ...restProps, + columns: nColumns, + }, + }), + }, + }); + setSelectedBlockId(blockId); + }; + + return ( + updateColumn(0, change)} + />, + updateColumn(1, change)} + />, + updateColumn(2, change)} + />, + ]} + /> + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts new file mode 100644 index 0000000..7e5d5cd --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container'; + +const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape; + +const ColumnsContainerPropsSchema = z.object({ + style: BaseColumnsContainerPropsSchema.shape.style, + props: z + .object({ + ...BasePropsShape, + columns: z.tuple([ + z.object({ childrenIds: z.array(z.string()) }), + z.object({ childrenIds: z.array(z.string()) }), + z.object({ childrenIds: z.array(z.string()) }), + ]), + }) + .optional() + .nullable(), +}); + +export type ColumnsContainerProps = z.infer; +export default ColumnsContainerPropsSchema; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerEditor.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerEditor.tsx new file mode 100644 index 0000000..6e027a9 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerEditor.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Container as BaseContainer } from '@usewaypoint/block-container'; + +import { useCurrentBlockId } from '../../editor/EditorBlock'; +import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext'; +import EditorChildrenIds from '../helpers/EditorChildrenIds'; + +import { ContainerProps } from './ContainerPropsSchema'; + +export default function ContainerEditor({ style, props }: ContainerProps) { + const childrenIds = props?.childrenIds ?? []; + + const document = useDocument(); + const currentBlockId = useCurrentBlockId(); + + return ( + + { + setDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'Container', + data: { + ...document[currentBlockId].data, + props: { childrenIds: childrenIds }, + }, + }, + }); + setSelectedBlockId(blockId); + }} + /> + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerPropsSchema.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerPropsSchema.tsx new file mode 100644 index 0000000..a0188f6 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/Container/ContainerPropsSchema.tsx @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container'; + +const ContainerPropsSchema = z.object({ + style: BaseContainerPropsSchema.shape.style, + props: z + .object({ + childrenIds: z.array(z.string()).optional().nullable(), + }) + .optional() + .nullable(), +}); + +export default ContainerPropsSchema; + +export type ContainerProps = z.infer; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutEditor.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutEditor.tsx new file mode 100644 index 0000000..0b5002d --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutEditor.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import { useCurrentBlockId } from '../../editor/EditorBlock'; +import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext'; +import EditorChildrenIds from '../helpers/EditorChildrenIds'; + +import { EmailLayoutProps } from './EmailLayoutPropsSchema'; + +function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) { + const f = fontFamily ?? 'MODERN_SANS'; + switch (f) { + case 'MODERN_SANS': + return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif'; + case 'BOOK_SANS': + return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif'; + case 'ORGANIC_SANS': + return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif'; + case 'GEOMETRIC_SANS': + return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif'; + case 'HEAVY_SANS': + return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif'; + case 'ROUNDED_SANS': + return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif'; + case 'MODERN_SERIF': + return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif'; + case 'BOOK_SERIF': + return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif'; + case 'MONOSPACE': + return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace'; + } +} + +export default function EmailLayoutEditor(props: EmailLayoutProps) { + const childrenIds = props.childrenIds ?? []; + const document = useDocument(); + const currentBlockId = useCurrentBlockId(); + + return ( +
{ + setSelectedBlockId(null); + }} + style={{ + backgroundColor: props.backdropColor ?? '#F5F5F5', + color: props.textColor ?? '#262626', + fontFamily: getFontFamily(props.fontFamily), + fontSize: '16px', + fontWeight: '400', + letterSpacing: '0.15008px', + lineHeight: '1.5', + margin: '0', + padding: '32px 0', + width: '100%', + minHeight: '100%', + }} + > + { + const v = props.borderColor; + if (!v) { + return undefined; + } + return `1px solid ${v}`; + })(), + }} + role="presentation" + cellSpacing="0" + cellPadding="0" + border={0} + > + + + + + +
+ { + setDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'EmailLayout', + data: { + ...document[currentBlockId].data, + childrenIds: childrenIds, + }, + }, + }); + setSelectedBlockId(blockId); + }} + /> +
+
+ ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx new file mode 100644 index 0000000..880f3ae --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const COLOR_SCHEMA = z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .optional(); + +const FONT_FAMILY_SCHEMA = z + .enum([ + 'MODERN_SANS', + 'BOOK_SANS', + 'ORGANIC_SANS', + 'GEOMETRIC_SANS', + 'HEAVY_SANS', + 'ROUNDED_SANS', + 'MODERN_SERIF', + 'BOOK_SERIF', + 'MONOSPACE', + ]) + .nullable() + .optional(); + +const EmailLayoutPropsSchema = z.object({ + backdropColor: COLOR_SCHEMA, + borderColor: COLOR_SCHEMA, + borderRadius: z.number().optional().nullable(), + canvasColor: COLOR_SCHEMA, + textColor: COLOR_SCHEMA, + fontFamily: FONT_FAMILY_SCHEMA, + childrenIds: z.array(z.string()).optional().nullable(), +}); + +export default EmailLayoutPropsSchema; + +export type EmailLayoutProps = z.infer; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx new file mode 100644 index 0000000..c045dc4 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Box, Button, SxProps, Typography } from '@mui/material'; + +type BlockMenuButtonProps = { + label: string; + icon: React.ReactNode; + onClick: () => void; +}; + +const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'column' }; +const ICON_SX: SxProps = { + mb: 0.75, + width: '100%', + bgcolor: 'cadet.200', + display: 'flex', + justifyContent: 'center', + p: 1, + border: '1px solid', + borderColor: 'cadet.300', +}; + +export default function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) { + return ( + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx new file mode 100644 index 0000000..5b64588 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { Box, Menu } from '@mui/material'; + +import { TEditorBlock } from '../../../../editor/core'; + +import BlockButton from './BlockButton'; +import { BUTTONS } from './buttons'; + +type BlocksMenuProps = { + anchorEl: HTMLElement | null; + setAnchorEl: (v: HTMLElement | null) => void; + onSelect: (block: TEditorBlock) => void; +}; +export default function BlocksMenu({ anchorEl, setAnchorEl, onSelect }: BlocksMenuProps) { + const onClose = () => { + setAnchorEl(null); + }; + + const onClick = (block: TEditorBlock) => { + onSelect(block); + setAnchorEl(null); + }; + + if (anchorEl === null) { + return null; + } + + return ( + + + {BUTTONS.map((k, i) => ( + onClick(k.block())} /> + ))} + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx new file mode 100644 index 0000000..3a0bba2 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; + +import { AddOutlined } from '@mui/icons-material'; +import { IconButton } from '@mui/material'; + +type Props = { + buttonElement: HTMLElement | null; + onClick: () => void; +}; +export default function DividerButton({ buttonElement, onClick }: Props) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + function listener({ clientX, clientY }: MouseEvent) { + if (!buttonElement) { + return; + } + const rect = buttonElement.getBoundingClientRect(); + const rectY = rect.y; + const bottomX = rect.x; + const topX = bottomX + rect.width; + + if (Math.abs(clientY - rectY) < 20) { + if (bottomX < clientX && clientX < topX) { + setVisible(true); + return; + } + } + setVisible(false); + } + window.addEventListener('mousemove', listener); + return () => { + window.removeEventListener('mousemove', listener); + }; + }, [buttonElement, setVisible]); + + return ( + { + ev.stopPropagation(); + onClick(); + }} + > + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx new file mode 100644 index 0000000..e14519a --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { AddOutlined } from '@mui/icons-material'; +import { ButtonBase } from '@mui/material'; + +type Props = { + onClick: () => void; +}; +export default function PlaceholderButton({ onClick }: Props) { + return ( + { + ev.stopPropagation(); + onClick(); + }} + sx={{ + display: 'flex', + alignContent: 'center', + justifyContent: 'center', + height: 48, + width: '100%', + bgcolor: 'rgba(0,0,0, 0.05)', + }} + > + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/buttons.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/buttons.tsx new file mode 100644 index 0000000..68168d9 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/buttons.tsx @@ -0,0 +1,162 @@ +import React from 'react'; + +import { + AccountCircleOutlined, + Crop32Outlined, + HMobiledataOutlined, + HorizontalRuleOutlined, + HtmlOutlined, + ImageOutlined, + LibraryAddOutlined, + NotesOutlined, + SmartButtonOutlined, + ViewColumnOutlined, +} from '@mui/icons-material'; + +import { TEditorBlock } from '../../../../editor/core'; + +type TButtonProps = { + label: string; + icon: JSX.Element; + block: () => TEditorBlock; +}; +export const BUTTONS: TButtonProps[] = [ + { + label: 'Heading', + icon: , + block: () => ({ + type: 'Heading', + data: { + props: { text: 'Hello friend' }, + style: { + padding: { top: 16, bottom: 16, left: 24, right: 24 }, + }, + }, + }), + }, + { + label: 'Text', + icon: , + block: () => ({ + type: 'Text', + data: { + props: { text: 'My new text block' }, + style: { + padding: { top: 16, bottom: 16, left: 24, right: 24 }, + fontWeight: 'normal', + }, + }, + }), + }, + + { + label: 'Button', + icon: , + block: () => ({ + type: 'Button', + data: { + props: { + text: 'Button', + url: 'https://www.usewaypoint.com', + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Image', + icon: , + block: () => ({ + type: 'Image', + data: { + props: { + // Network-neutral empty placeholder (host supplies real URLs). + url: '', + alt: 'Sample product', + contentAlignment: 'middle', + linkHref: null, + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Avatar', + icon: , + block: () => ({ + type: 'Avatar', + data: { + props: { + imageUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Crect fill='%23E5E5E5' width='128' height='128'/%3E%3C/svg%3E", + shape: 'circle', + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Divider', + icon: , + block: () => ({ + type: 'Divider', + data: { + style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } }, + props: { + lineColor: '#CCCCCC', + }, + }, + }), + }, + { + label: 'Spacer', + icon: , + block: () => ({ + type: 'Spacer', + data: {}, + }), + }, + { + label: 'Html', + icon: , + block: () => ({ + type: 'Html', + data: { + props: { contents: 'Hello world' }, + style: { + fontSize: 16, + textAlign: null, + padding: { top: 16, bottom: 16, left: 24, right: 24 }, + }, + }, + }), + }, + { + label: 'Columns', + icon: , + block: () => ({ + type: 'ColumnsContainer', + data: { + props: { + columnsGap: 16, + columnsCount: 3, + columns: [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }], + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Container', + icon: , + block: () => ({ + type: 'Container', + data: { + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + + // { label: 'ProgressBar', icon: , block: () => ({}) }, + // { label: 'LoopContainer', icon: , block: () => ({}) }, +]; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx new file mode 100644 index 0000000..69cd8ad --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; + +import { TEditorBlock } from '../../../../editor/core'; + +import BlocksMenu from './BlocksMenu'; +import DividerButton from './DividerButton'; +import PlaceholderButton from './PlaceholderButton'; + +type Props = { + placeholder?: boolean; + onSelect: (block: TEditorBlock) => void; +}; +export default function AddBlockButton({ onSelect, placeholder }: Props) { + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [buttonElement, setButtonElement] = useState(null); + + const handleButtonClick = () => { + setMenuAnchorEl(buttonElement); + }; + + const renderButton = () => { + if (placeholder) { + return ; + } else { + return ; + } + }; + + return ( + <> +
+ {renderButton()} +
+ + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/index.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/index.tsx new file mode 100644 index 0000000..eee5313 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/EditorChildrenIds/index.tsx @@ -0,0 +1,58 @@ +import React, { Fragment } from 'react'; + +import { TEditorBlock } from '../../../editor/core'; +import EditorBlock from '../../../editor/EditorBlock'; + +import AddBlockButton from './AddBlockMenu'; + +export type EditorChildrenChange = { + blockId: string; + block: TEditorBlock; + childrenIds: string[]; +}; + +function generateId() { + return `block-${Date.now()}`; +} + +export type EditorChildrenIdsProps = { + childrenIds: string[] | null | undefined; + onChange: (val: EditorChildrenChange) => void; +}; +export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) { + const appendBlock = (block: TEditorBlock) => { + const blockId = generateId(); + return onChange({ + blockId, + block, + childrenIds: [...(childrenIds || []), blockId], + }); + }; + + const insertBlock = (block: TEditorBlock, index: number) => { + const blockId = generateId(); + const newChildrenIds = [...(childrenIds || [])]; + newChildrenIds.splice(index, 0, blockId); + return onChange({ + blockId, + block, + childrenIds: newChildrenIds, + }); + }; + + if (!childrenIds || childrenIds.length === 0) { + return ; + } + + return ( + <> + {childrenIds.map((childId, i) => ( + + insertBlock(block, i)} /> + + + ))} + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/TStyle.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/TStyle.ts new file mode 100644 index 0000000..5903d1d --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/TStyle.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type TStyle = { + backgroundColor?: any; + borderColor?: any; + borderRadius?: any; + color?: any; + fontFamily?: any; + fontSize?: any; + fontWeight?: any; + padding?: any; + textAlign?: any; +}; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx new file mode 100644 index 0000000..300c1ea --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx @@ -0,0 +1,72 @@ +import React, { CSSProperties, useState } from 'react'; + +import { Box } from '@mui/material'; + +import { useCurrentBlockId } from '../../../editor/EditorBlock'; +import { setSelectedBlockId, useSelectedBlockId } from '../../../editor/EditorContext'; + +import TuneMenu from './TuneMenu'; + +type TEditorBlockWrapperProps = { + children: JSX.Element; +}; + +export default function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) { + const selectedBlockId = useSelectedBlockId(); + const [mouseInside, setMouseInside] = useState(false); + const blockId = useCurrentBlockId(); + + let outline: CSSProperties['outline']; + if (selectedBlockId === blockId) { + outline = '2px solid rgba(0,121,204, 1)'; + } else if (mouseInside) { + outline = '2px solid rgba(0,121,204, 0.3)'; + } + + const selectBlock = () => { + setSelectedBlockId(blockId); + }; + + const renderMenu = () => { + if (selectedBlockId !== blockId) { + return null; + } + return ; + }; + + return ( + { + setMouseInside(true); + ev.stopPropagation(); + }} + onMouseLeave={() => { + setMouseInside(false); + }} + onClick={(ev) => { + selectBlock(); + ev.stopPropagation(); + ev.preventDefault(); + }} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + ev.stopPropagation(); + selectBlock(); + } + }} + > + {renderMenu()} + {children} + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx new file mode 100644 index 0000000..91a4c72 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx @@ -0,0 +1,26 @@ +import React, { CSSProperties } from 'react'; + +import { TStyle } from '../TStyle'; + +type TReaderBlockWrapperProps = { + style: TStyle; + children: JSX.Element; +}; + +export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) { + const { padding, borderColor, ...restStyle } = style; + const cssStyle: CSSProperties = { + ...restStyle, + }; + + if (padding) { + const { top, bottom, left, right } = padding; + cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`; + } + + if (borderColor) { + cssStyle.border = `1px solid ${borderColor}`; + } + + return
{children}
; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/TuneMenu.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/TuneMenu.tsx new file mode 100644 index 0000000..d7f38d7 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/block-wrappers/TuneMenu.tsx @@ -0,0 +1,221 @@ +import React from 'react'; + +import { + ArrowDownwardOutlined, + ArrowUpwardOutlined, + ContentCopyOutlined, + DeleteOutlined, +} from '@mui/icons-material'; +import { IconButton, Paper, Stack, SxProps, Tooltip } from '@mui/material'; + +import { TEditorBlock, TEditorConfiguration } from '../../../editor/core'; +import { resetDocument, setSelectedBlockId, useDocument } from '../../../editor/EditorContext'; +import { ColumnsContainerProps } from '../../ColumnsContainer/ColumnsContainerPropsSchema'; +import cloneDocumentBlock from '../cloneDocumentBlock'; +import { deleteBlockSubtree } from '../deleteBlockSubtree'; + +const sx: SxProps = { + position: 'absolute', + top: 0, + left: -56, + borderRadius: 64, + paddingX: 0.5, + paddingY: 1, + zIndex: 'fab', +}; + +function findParentBlockId(blockId: string, document: TEditorConfiguration) { + for (const [id, b] of Object.entries(document)) { + if (id === blockId) { + continue; + } + const block = b as TEditorBlock; + switch (block.type) { + case 'EmailLayout': + if (block.data.childrenIds?.includes(blockId)) { + return id; + } + break; + case 'Container': + if (block.data.props?.childrenIds?.includes(blockId)) { + return id; + } + break; + case 'ColumnsContainer': + if (block.data.props?.columns?.some((col) => col?.childrenIds?.includes(blockId))) { + return id; + } + break; + } + } + return null; +} + +type Props = { + blockId: string; +}; +export default function TuneMenu({ blockId }: Props) { + const document = useDocument(); + + const handleDuplicateClick = () => { + const parentBlockId = findParentBlockId(blockId, document); + + const { document: newDocument, blockId: newBlockId } = cloneDocumentBlock(document, blockId); + + if (parentBlockId) { + const parentBlock = newDocument[parentBlockId]; + switch (parentBlock.type) { + case 'EmailLayout': { + if (!parentBlock.data.childrenIds) { + parentBlock.data.childrenIds = []; + } + const index = parentBlock.data.childrenIds.indexOf(blockId); + parentBlock.data.childrenIds.splice(index + 1, 0, newBlockId); + break; + } + case 'Container': { + if (!parentBlock.data.props) { + parentBlock.data.props = {}; + } + if (!parentBlock.data.props.childrenIds) { + parentBlock.data.props.childrenIds = []; + } + const index = parentBlock.data.props.childrenIds.indexOf(blockId); + parentBlock.data.props.childrenIds.splice(index + 1, 0, newBlockId); + break; + } + case 'ColumnsContainer': + if (!parentBlock.data.props) { + parentBlock.data.props = { + columns: [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }], + }; + } + if (!parentBlock.data.props.columns) { + parentBlock.data.props.columns = [ + { childrenIds: [] }, + { childrenIds: [] }, + { childrenIds: [] }, + ]; + } + + for (const column of parentBlock.data.props.columns) { + if (!column?.childrenIds) { + continue; + } + if (column.childrenIds.includes(blockId)) { + const index = column.childrenIds.indexOf(blockId); + column.childrenIds.splice(index + 1, 0, newBlockId); + } + } + break; + } + + resetDocument(newDocument); + setSelectedBlockId(newBlockId); + } + }; + + const handleDeleteClick = () => { + resetDocument(deleteBlockSubtree(document, blockId)); + setSelectedBlockId(null); + }; + + const handleMoveClick = (direction: 'up' | 'down') => { + const moveChildrenIds = (ids: string[] | null | undefined) => { + if (!ids) { + return ids; + } + const index = ids.indexOf(blockId); + if (index < 0) { + return ids; + } + + const childrenIds = [...ids]; + if (direction === 'up' && index > 0) { + [childrenIds[index], childrenIds[index - 1]] = [childrenIds[index - 1], childrenIds[index]]; + } else if (direction === 'down' && index < childrenIds.length - 1) { + [childrenIds[index], childrenIds[index + 1]] = [childrenIds[index + 1], childrenIds[index]]; + } + return childrenIds; + }; + + const nDocument: typeof document = { ...document }; + for (const [id, b] of Object.entries(nDocument)) { + const block = b as TEditorBlock; + if (id === blockId) { + continue; + } + + switch (block.type) { + case 'EmailLayout': + nDocument[id] = { + ...block, + data: { + ...block.data, + childrenIds: moveChildrenIds(block.data.childrenIds), + }, + }; + break; + case 'Container': + nDocument[id] = { + ...block, + data: { + ...block.data, + props: { + ...block.data.props, + childrenIds: moveChildrenIds(block.data.props?.childrenIds), + }, + }, + }; + break; + case 'ColumnsContainer': + nDocument[id] = { + type: 'ColumnsContainer', + data: { + style: block.data.style, + props: { + ...block.data.props, + columns: block.data.props?.columns?.map((c) => ({ + ...c, + childrenIds: moveChildrenIds(c?.childrenIds) ?? [], + })), + }, + } as ColumnsContainerProps, + }; + break; + default: + nDocument[id] = block; + } + } + + resetDocument(nDocument); + setSelectedBlockId(blockId); + }; + + return ( + ev.stopPropagation()}> + + + handleMoveClick('up')} sx={{ color: 'text.primary' }}> + + + + + handleMoveClick('down')} sx={{ color: 'text.primary' }}> + + + + + + + + + + + + + + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.spec.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.spec.ts new file mode 100644 index 0000000..34f190c --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.spec.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { TEditorConfiguration } from '../../editor/core'; +import cloneDocumentBlock, { createBlockId } from './cloneDocumentBlock'; + +describe('cloneDocumentBlock', () => { + it('creates collision-safe ids that do not overwrite existing keys', () => { + const document = { + root: { type: 'EmailLayout', data: { childrenIds: ['a'] } }, + a: { type: 'Text', data: { props: { text: 'x' } } }, + } as unknown as TEditorConfiguration; + + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const id = createBlockId(document); + assert.equal(Object.prototype.hasOwnProperty.call(document, id), false); + assert.equal(ids.has(id), false); + ids.add(id); + document[id] = { type: 'Spacer', data: {} } as TEditorConfiguration[string]; + } + }); + + it('clones ColumnsContainer with missing/partial columns without throwing', () => { + const document = { + root: { type: 'EmailLayout', data: { childrenIds: ['cols'] } }, + cols: { + type: 'ColumnsContainer', + data: { + props: { + columns: [{ childrenIds: ['child'] }, undefined], + }, + }, + }, + child: { type: 'Text', data: { props: { text: 'c' } } }, + } as unknown as TEditorConfiguration; + + const { document: next, blockId } = cloneDocumentBlock(document, 'cols'); + assert.ok(next[blockId]); + const cloned = next[blockId] as { + type: string; + data: { props: { columns: Array<{ childrenIds: string[] } | undefined> } }; + }; + assert.equal(cloned.type, 'ColumnsContainer'); + assert.equal(cloned.data.props.columns[0]?.childrenIds.length, 1); + assert.notEqual(cloned.data.props.columns[0]?.childrenIds[0], 'child'); + }); +}); diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.ts new file mode 100644 index 0000000..0f2deb0 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/cloneDocumentBlock.ts @@ -0,0 +1,82 @@ +import { TEditorBlock, TEditorConfiguration } from '../../editor/core'; + +type TResult = { + document: TEditorConfiguration; + blockId: string; +}; + +/** Collision-safe block id; prefers `crypto.randomUUID` when available. */ +export function createBlockId(document: TEditorConfiguration): string { + const taken = (id: string) => Object.prototype.hasOwnProperty.call(document, id); + + if (typeof globalThis.crypto?.randomUUID === 'function') { + let id: string; + do { + id = `block-${globalThis.crypto.randomUUID()}`; + } while (taken(id)); + return id; + } + + let n = 0; + let id: string; + do { + id = `block-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}-${n++}`; + } while (taken(id)); + return id; +} + +function cloneChildrenIds(document: TEditorConfiguration, blockIds: string[]): string[] { + return blockIds.map((blockId) => { + const newBlock = cloneBlock(document, blockId); + const newBlockId = createBlockId(document); + document[newBlockId] = newBlock; + return newBlockId; + }); +} + +function cloneBlock(document: TEditorConfiguration, blockId: string): TEditorBlock { + const clone = structuredClone(document[blockId]); + switch (clone.type) { + case 'EmailLayout': + throw new Error('Cloning EmailLayout blocks is not supported.'); + case 'Avatar': + case 'Button': + case 'Divider': + case 'Heading': + case 'Html': + case 'Image': + case 'Spacer': + case 'Text': + return clone; + case 'Container': + if (clone.data?.props?.childrenIds) { + clone.data.props.childrenIds = cloneChildrenIds(document, clone.data.props.childrenIds); + } + return clone; + case 'ColumnsContainer': { + const props = clone.data?.props; + const columns = props?.columns; + if (props && columns) { + props.columns = columns.map((col) => ({ + ...(col ?? { childrenIds: [] }), + childrenIds: cloneChildrenIds(document, col?.childrenIds ?? []), + })) as typeof columns; + } + return clone; + } + } +} + +export default function cloneDocumentBlock( + originalDocument: TEditorConfiguration, + originalBlockId: string, +): TResult { + const document = { ...originalDocument }; + const cloned = cloneBlock(document, originalBlockId); + const blockId = createBlockId(document); + document[blockId] = cloned; + return { + document, + blockId, + }; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.spec.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.spec.ts new file mode 100644 index 0000000..d50fdaa --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.spec.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { TEditorConfiguration } from '../../editor/core'; +import { collectSubtreeBlockIds, deleteBlockSubtree } from './deleteBlockSubtree'; + +const nestedDoc = { + root: { + type: 'EmailLayout', + data: { childrenIds: ['container-1'] }, + }, + 'container-1': { + type: 'Container', + data: { + props: { childrenIds: ['text-1', 'columns-1'] }, + }, + }, + 'text-1': { + type: 'Text', + data: { props: { text: 'hello' } }, + }, + 'columns-1': { + type: 'ColumnsContainer', + data: { + props: { + columns: [{ childrenIds: ['inner-a'] }, { childrenIds: [] }, undefined], + }, + }, + }, + 'inner-a': { + type: 'Heading', + data: { props: { text: 'col' } }, + }, + 'orphan-keep': { + type: 'Spacer', + data: {}, + }, +} as unknown as TEditorConfiguration; + +describe('deleteBlockSubtree', () => { + it('collects container and columns descendants', () => { + const ids = collectSubtreeBlockIds(nestedDoc, 'container-1'); + assert.deepEqual(ids.sort(), ['columns-1', 'container-1', 'inner-a', 'text-1'].sort()); + }); + + it('removes the full subtree and parent references without touching siblings', () => { + const next = deleteBlockSubtree(nestedDoc, 'container-1'); + assert.equal(next['container-1'], undefined); + assert.equal(next['text-1'], undefined); + assert.equal(next['columns-1'], undefined); + assert.equal(next['inner-a'], undefined); + assert.ok(next['orphan-keep']); + assert.deepEqual((next.root as { data: { childrenIds: string[] } }).data.childrenIds, []); + }); + + it('tolerates missing columns when deleting a sibling', () => { + const next = deleteBlockSubtree(nestedDoc, 'orphan-keep'); + assert.equal(next['orphan-keep'], undefined); + assert.ok(next['container-1']); + }); +}); diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.ts new file mode 100644 index 0000000..7dc3389 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/deleteBlockSubtree.ts @@ -0,0 +1,103 @@ +import { TEditorBlock, TEditorConfiguration } from '../../editor/core'; +import type { ColumnsContainerProps } from '../ColumnsContainer/ColumnsContainerPropsSchema'; + +/** Collect `blockId` and all descendant block ids (Container / ColumnsContainer). */ +export function collectSubtreeBlockIds(document: TEditorConfiguration, blockId: string): string[] { + const ids: string[] = []; + const visit = (id: string) => { + ids.push(id); + const block = document[id] as TEditorBlock | undefined; + if (!block) { + return; + } + switch (block.type) { + case 'Container': + for (const childId of block.data.props?.childrenIds ?? []) { + visit(childId); + } + break; + case 'ColumnsContainer': + for (const col of block.data.props?.columns ?? []) { + for (const childId of col?.childrenIds ?? []) { + visit(childId); + } + } + break; + default: + break; + } + }; + visit(blockId); + return ids; +} + +/** + * Remove `blockId` (and its subtree) from parent references and delete those + * records from the document. Returns a shallow-copied document map. + */ +export function deleteBlockSubtree( + document: TEditorConfiguration, + blockId: string, +): TEditorConfiguration { + const toDelete = new Set(collectSubtreeBlockIds(document, blockId)); + const filterChildrenIds = (childrenIds: string[] | null | undefined) => { + if (!childrenIds) { + return childrenIds; + } + return childrenIds.filter((f) => !toDelete.has(f)); + }; + + const nDocument: TEditorConfiguration = { ...document }; + for (const [id, b] of Object.entries(nDocument)) { + const block = b as TEditorBlock; + if (toDelete.has(id)) { + continue; + } + + switch (block.type) { + case 'EmailLayout': + nDocument[id] = { + ...block, + data: { + ...block.data, + childrenIds: filterChildrenIds(block.data.childrenIds), + }, + }; + break; + case 'Container': + nDocument[id] = { + ...block, + data: { + ...block.data, + props: { + ...block.data.props, + childrenIds: filterChildrenIds(block.data.props?.childrenIds), + }, + }, + }; + break; + case 'ColumnsContainer': + nDocument[id] = { + type: 'ColumnsContainer', + data: { + style: block.data.style, + props: { + ...block.data.props, + columns: block.data.props?.columns?.map((c) => ({ + ...(c ?? { childrenIds: [] }), + childrenIds: filterChildrenIds(c?.childrenIds) ?? [], + })), + }, + } as ColumnsContainerProps, + }; + break; + default: + nDocument[id] = block; + } + } + + for (const id of toDelete) { + delete nDocument[id]; + } + return nDocument; +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/fontFamily.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/fontFamily.ts new file mode 100644 index 0000000..8cebb5c --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/fontFamily.ts @@ -0,0 +1,62 @@ +export const FONT_FAMILIES = [ + { + key: 'MODERN_SANS', + label: 'Modern sans', + value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif', + }, + { + key: 'BOOK_SANS', + label: 'Book sans', + value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif', + }, + { + key: 'ORGANIC_SANS', + label: 'Organic sans', + value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif', + }, + { + key: 'GEOMETRIC_SANS', + label: 'Geometric sans', + value: + 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif', + }, + { + key: 'HEAVY_SANS', + label: 'Heavy sans', + value: + 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif', + }, + { + key: 'ROUNDED_SANS', + label: 'Rounded sans', + value: + 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif', + }, + { + key: 'MODERN_SERIF', + label: 'Modern serif', + value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif', + }, + { + key: 'BOOK_SERIF', + label: 'Book serif', + value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif', + }, + { + key: 'MONOSPACE', + label: 'Monospace', + value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace', + }, +]; + +export const FONT_FAMILY_NAMES = [ + 'MODERN_SANS', + 'BOOK_SANS', + 'ORGANIC_SANS', + 'GEOMETRIC_SANS', + 'HEAVY_SANS', + 'ROUNDED_SANS', + 'MODERN_SERIF', + 'BOOK_SERIF', + 'MONOSPACE', +] as const; diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/zod.ts b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/zod.ts new file mode 100644 index 0000000..7c975e2 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/blocks/helpers/zod.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { FONT_FAMILY_NAMES } from './fontFamily'; + +export function zColor() { + return z.string().regex(/^#[0-9a-fA-F]{6}$/); +} + +export function zFontFamily() { + return z.enum(FONT_FAMILY_NAMES); +} + +export function zFontWeight() { + return z.enum(['bold', 'normal']); +} + +export function zTextAlign() { + return z.enum(['left', 'center', 'right']); +} + +export function zPadding() { + return z.object({ + top: z.number(), + bottom: z.number(), + right: z.number(), + left: z.number(), + }); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorBlock.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorBlock.tsx new file mode 100644 index 0000000..a600b37 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorBlock.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext } from 'react'; + +import { EditorBlock as CoreEditorBlock } from './core'; +import { useDocument } from './EditorContext'; + +const EditorBlockContext = createContext(null); +export const useCurrentBlockId = () => useContext(EditorBlockContext)!; + +type EditorBlockProps = { + id: string; +}; + +/** + * + * @param id - Block id + * @returns EditorBlock component that loads data from the EditorDocumentContext + */ +export default function EditorBlock({ id }: EditorBlockProps) { + const document = useDocument(); + const block = document[id]; + if (!block) { + throw new Error('Could not find block'); + } + return ( + + + + ); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorContext.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorContext.tsx new file mode 100644 index 0000000..f3db181 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/editor/EditorContext.tsx @@ -0,0 +1,137 @@ +import { useStore } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +import { EMPTY_EMAIL } from '../../getConfiguration'; + +import { TEditorConfiguration } from './core'; + +type TValue = { + document: TEditorConfiguration; + + selectedBlockId: string | null; + selectedSidebarTab: 'block-configuration' | 'styles'; + selectedMainTab: 'editor' | 'preview' | 'json' | 'html'; + selectedScreenSize: 'desktop' | 'mobile'; + + inspectorDrawerOpen: boolean; + samplesDrawerOpen: boolean; +}; + +const editorStateStore = createStore(() => ({ + document: EMPTY_EMAIL, + selectedBlockId: null, + selectedSidebarTab: 'styles', + selectedMainTab: 'editor', + selectedScreenSize: 'desktop', + + inspectorDrawerOpen: true, + samplesDrawerOpen: true, +})); + +/** + * Zustand's SSR path uses `getInitialState` unless `getServerState` is set on + * the vanilla store API. Without this, `resetDocument` updates `getState()` but + * hooks still see EMPTY under `renderToStaticMarkup` / SSR. + */ +( + editorStateStore as typeof editorStateStore & { + getServerState: () => TValue; + } +).getServerState = () => editorStateStore.getState(); + +export function useDocument() { + return useStore(editorStateStore, (s) => s.document); +} + +export function useSelectedBlockId() { + return useStore(editorStateStore, (s) => s.selectedBlockId); +} + +export function useSelectedScreenSize() { + return useStore(editorStateStore, (s) => s.selectedScreenSize); +} + +export function useSelectedMainTab() { + return useStore(editorStateStore, (s) => s.selectedMainTab); +} + +export function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) { + return editorStateStore.setState({ selectedMainTab }); +} + +export function useSelectedSidebarTab() { + return useStore(editorStateStore, (s) => s.selectedSidebarTab); +} + +export function useInspectorDrawerOpen() { + return useStore(editorStateStore, (s) => s.inspectorDrawerOpen); +} + +export function useSamplesDrawerOpen() { + return useStore(editorStateStore, (s) => s.samplesDrawerOpen); +} + +export function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) { + const selectedSidebarTab = selectedBlockId === null ? 'styles' : 'block-configuration'; + const options: Partial = {}; + if (selectedBlockId !== null) { + options.inspectorDrawerOpen = true; + } + return editorStateStore.setState({ + selectedBlockId, + selectedSidebarTab, + ...options, + }); +} + +export function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) { + return editorStateStore.setState({ selectedSidebarTab }); +} + +export function getDocument(): TEditorConfiguration { + return editorStateStore.getState().document; +} + +export function resetDocument(document: TValue['document']) { + return editorStateStore.setState({ + document, + selectedSidebarTab: 'styles', + selectedBlockId: null, + }); +} + +export function setDocument(document: TValue['document']) { + const originalDocument = editorStateStore.getState().document; + return editorStateStore.setState({ + document: { + ...originalDocument, + ...document, + }, + }); +} + +export function toggleInspectorDrawerOpen() { + const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen; + return editorStateStore.setState({ inspectorDrawerOpen }); +} + +export function toggleSamplesDrawerOpen() { + const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen; + return editorStateStore.setState({ samplesDrawerOpen }); +} + +export function setSelectedScreenSize(selectedScreenSize: TValue['selectedScreenSize']) { + return editorStateStore.setState({ selectedScreenSize }); +} + +/** + * Subscribe to document changes in the editor store. + * Returns an unsubscribe function. + */ +export function subscribeDocument(listener: (document: TEditorConfiguration) => void): () => void { + return editorStateStore.subscribe((state, prevState) => { + if (state.document !== prevState.document) { + listener(state.document); + } + }); +} diff --git a/packages/post-kit-editor/src/email-builder-ui/documents/editor/core.tsx b/packages/post-kit-editor/src/email-builder-ui/documents/editor/core.tsx new file mode 100644 index 0000000..2314311 --- /dev/null +++ b/packages/post-kit-editor/src/email-builder-ui/documents/editor/core.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { z } from 'zod'; + +import { Avatar, AvatarPropsSchema } from '@usewaypoint/block-avatar'; +import { Button, ButtonPropsSchema } from '@usewaypoint/block-button'; +import { Divider, DividerPropsSchema } from '@usewaypoint/block-divider'; +import { Heading, HeadingPropsSchema } from '@usewaypoint/block-heading'; +import { Html, HtmlPropsSchema } from '@usewaypoint/block-html'; +import { Image, ImagePropsSchema } from '@usewaypoint/block-image'; +import { Spacer, SpacerPropsSchema } from '@usewaypoint/block-spacer'; +import { Text, TextPropsSchema } from '@usewaypoint/block-text'; +import { + buildBlockComponent, + buildBlockConfigurationDictionary, + buildBlockConfigurationSchema, +} from '@usewaypoint/document-core'; + +import ColumnsContainerEditor from '../blocks/ColumnsContainer/ColumnsContainerEditor'; +import ColumnsContainerPropsSchema from '../blocks/ColumnsContainer/ColumnsContainerPropsSchema'; +import ContainerEditor from '../blocks/Container/ContainerEditor'; +import ContainerPropsSchema from '../blocks/Container/ContainerPropsSchema'; +import EmailLayoutEditor from '../blocks/EmailLayout/EmailLayoutEditor'; +import EmailLayoutPropsSchema from '../blocks/EmailLayout/EmailLayoutPropsSchema'; +import EditorBlockWrapper from '../blocks/helpers/block-wrappers/EditorBlockWrapper'; + +const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({ + Avatar: { + schema: AvatarPropsSchema, + Component: (props) => ( + + + + ), + }, + Button: { + schema: ButtonPropsSchema, + Component: (props) => ( + + + +

+ + ${storyBlocks} + + + +`; +} diff --git a/packages/post-kit-editor/tests/visual.mjs b/packages/post-kit-editor/tests/visual.mjs new file mode 100644 index 0000000..5cf2a58 --- /dev/null +++ b/packages/post-kit-editor/tests/visual.mjs @@ -0,0 +1,348 @@ +/** + * Storybook visual screenshots for PR review, with committed baseline comparison. + * + * Requires `storybook-static/` from `pnpm build-storybook` in this package. + * Serves that directory, screenshots a small set of iframe stories, then + * pixel-diffs against `visual-baselines/`. + * + * Output under `test-results/visual/`: + * - `pr/-.png` — this PR + * - `base/-.png` — committed baseline (when present) + * - `diff/-.png` — red pixel diff (when both exist and differ) + * - `index.html` — side-by-side review page + * - `manifest.json` — includes `summary` for CI gating + * + * Capture always exits 0 on successful screenshots. Use `pnpm test:visual:gate` + * (or CI job `visual-review`) to fail when stories are `changed` or `new`. + * + * Env: + * - VISUAL_PORT — fixed local port (default: ephemeral) + * - VISUAL_SKIP_BASE — set to `1` to only capture PR shots (all status=new) + */ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PNG } from 'pngjs'; +import pixelmatch from 'pixelmatch'; +import { chromium } from 'playwright'; +import { buildReportHtml } from './visual-report-html.mjs'; +import { isPathInsideRoot } from './is-path-inside-root.mjs'; + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); +const storybookDir = path.join(rootDir, 'storybook-static'); +const baselinesDir = path.join(rootDir, 'visual-baselines'); +const outDir = path.join(rootDir, 'test-results', 'visual'); +const port = Number(process.env.VISUAL_PORT ?? 0); +const skipBase = process.env.VISUAL_SKIP_BASE === '1'; +const baseLabel = `committed:${path.relative(rootDir, baselinesDir) || 'visual-baselines'}`; + +/** @type {{ id: string; waitFor: string[] }[]} */ +const STORIES = [ + { + id: 'admin-email-builder-overview--full-admin', + waitFor: ['pk-admin-root', 'pk-editor-eb-mui-surface'], + }, + { + id: 'admin-email-builder-blocks-text--default', + waitFor: ['pk-editor-eb-mui-surface'], + }, + { + id: 'admin-email-builder-templates--welcome-editor', + waitFor: ['pk-editor-eb-mui-surface'], + }, +]; + +const VIEWPORTS = [{ name: 'desktop', width: 1440, height: 900 }]; + +if (!existsSync(storybookDir)) { + throw new Error( + `Missing storybook-static at ${storybookDir}. Run: pnpm --filter @singleton-sd/post-kit-editor build-storybook`, + ); +} + +function contentTypeForPath(pathname) { + if (pathname.endsWith('.html')) return 'text/html; charset=utf-8'; + if (pathname.endsWith('.css')) return 'text/css; charset=utf-8'; + if (pathname.endsWith('.js') || pathname.endsWith('.mjs')) { + return 'text/javascript; charset=utf-8'; + } + if (pathname.endsWith('.json')) return 'application/json; charset=utf-8'; + if (pathname.endsWith('.svg')) return 'image/svg+xml'; + if (pathname.endsWith('.ico')) return 'image/x-icon'; + if (pathname.endsWith('.woff2')) return 'font/woff2'; + if (pathname.endsWith('.woff')) return 'font/woff'; + if (pathname.endsWith('.ttf')) return 'font/ttf'; + if (pathname.endsWith('.png')) return 'image/png'; + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (pathname.endsWith('.webp')) return 'image/webp'; + if (pathname.endsWith('.map')) return 'application/json'; + return 'application/octet-stream'; +} + +function normalizePathname(urlPath) { + const cleaned = urlPath.replace(/\?.*$/, '').replace(/\/+$/, '') || '/'; + if (cleaned === '/') return '/index.html'; + if (path.extname(cleaned)) return cleaned; + const asDir = `${cleaned}/index.html`; + if (existsSync(path.join(storybookDir, asDir))) return asDir; + return cleaned; +} + +function createStaticServer(root) { + return http.createServer(async (req, res) => { + try { + if (!req.url) { + res.statusCode = 400; + res.end('Bad request'); + return; + } + + const pathname = normalizePathname(req.url); + // Drop leading slash so join stays under root on POSIX. + const relativePath = pathname.replace(/^[/\\]+/, ''); + const fsPath = path.resolve(root, relativePath); + + if (!isPathInsideRoot(root, fsPath) || !existsSync(fsPath)) { + res.statusCode = 404; + res.end('Not found'); + return; + } + + res.setHeader('Content-Type', contentTypeForPath(pathname)); + if ( + pathname.endsWith('.html') || + pathname.endsWith('.css') || + pathname.endsWith('.js') || + pathname.endsWith('.mjs') || + pathname.endsWith('.json') || + pathname.endsWith('.svg') || + pathname.endsWith('.map') + ) { + res.end(await readFile(fsPath)); + return; + } + + createReadStream(fsPath).pipe(res); + } catch (err) { + res.statusCode = 500; + res.end(String(err)); + } + }); +} + +async function listen(server, preferredPort = 0) { + await new Promise((resolve) => server.listen(preferredPort, '127.0.0.1', resolve)); + const actual = server.address()?.port; + assert.ok(typeof actual === 'number' && actual > 0, 'Failed to pick a port'); + return actual; +} + +function storySlug(storyId) { + return storyId.replaceAll('--', '-'); +} + +function padPng(img, width, height) { + if (img.width === width && img.height === height) return img; + const out = new PNG({ width, height }); + out.data.fill(0); + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const src = (img.width * y + x) << 2; + const dst = (width * y + x) << 2; + out.data[dst] = img.data[src]; + out.data[dst + 1] = img.data[src + 1]; + out.data[dst + 2] = img.data[src + 2]; + out.data[dst + 3] = img.data[src + 3]; + } + } + return out; +} + +function diffPngs(baseFilePath, prFilePath, diffFilePath) { + const baseImg = PNG.sync.read(readFileSync(baseFilePath)); + const prImg = PNG.sync.read(readFileSync(prFilePath)); + const width = Math.max(baseImg.width, prImg.width); + const height = Math.max(baseImg.height, prImg.height); + const baseNorm = padPng(baseImg, width, height); + const prNorm = padPng(prImg, width, height); + const diff = new PNG({ width, height }); + const mismatched = pixelmatch(baseNorm.data, prNorm.data, diff.data, width, height, { + threshold: 0.1, + }); + mkdirSync(path.dirname(diffFilePath), { recursive: true }); + writeFileSync(diffFilePath, PNG.sync.write(diff)); + return mismatched; +} + +/** + * @param {import('playwright').Browser} browser + * @param {string} url + * @param {{ width: number; height: number }} viewport + * @param {string} filePath + * @param {string[]} waitForTestIds + */ +async function screenshotStory(browser, url, viewport, filePath, waitForTestIds) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + try { + const response = await page.goto(url, { + waitUntil: 'networkidle', + timeout: 90_000, + }); + const status = response?.status() ?? 0; + if (!response || status >= 400) { + return { ok: false, status }; + } + + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + caret-color: transparent !important; + } + `, + }); + + for (const testId of waitForTestIds) { + await page.waitForSelector(`[data-testid="${testId}"]`, { + timeout: 60_000, + state: 'visible', + }); + } + + await page.evaluate(async () => { + /* eslint-disable no-undef -- browser page context */ + if (document.fonts?.ready) { + await document.fonts.ready; + } + /* eslint-enable no-undef */ + }); + await page.waitForTimeout(250); + + await page.screenshot({ + path: filePath, + fullPage: true, + animations: 'disabled', + }); + return { ok: true, status }; + } finally { + await context.close(); + } +} + +const server = createStaticServer(storybookDir); +const actualPort = await listen(server, port); +const origin = `http://127.0.0.1:${actualPort}`; + +await rm(outDir, { recursive: true, force: true }); +for (const dir of ['pr', 'base', 'diff']) { + mkdirSync(path.join(outDir, dir), { recursive: true }); +} + +const browser = await chromium.launch(); +const manifest = []; + +try { + for (const story of STORIES) { + for (const viewport of VIEWPORTS) { + const slug = `${storySlug(story.id)}-${viewport.name}`; + const prRel = `pr/${slug}.png`; + const baseRel = `base/${slug}.png`; + const diffRel = `diff/${slug}.png`; + const prFile = path.join(outDir, prRel); + const baseOutFile = path.join(outDir, baseRel); + const diffFile = path.join(outDir, diffRel); + const baselineFile = path.join(baselinesDir, `${slug}.png`); + + const storyUrl = `${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story`; + const prShot = await screenshotStory(browser, storyUrl, viewport, prFile, story.waitFor); + assert.ok( + prShot.ok && prShot.status < 400, + `Failed to load story ${story.id}: HTTP ${prShot.status}`, + ); + + let baseOk = false; + let mismatched = 0; + if (!skipBase && existsSync(baselineFile)) { + copyFileSync(baselineFile, baseOutFile); + baseOk = true; + mismatched = diffPngs(baseOutFile, prFile, diffFile); + if (mismatched === 0) { + await rm(diffFile, { force: true }); + } + } + + const status = !baseOk ? 'new' : mismatched > 0 ? 'changed' : 'unchanged'; + + manifest.push({ + story: story.id, + viewport: viewport.name, + prFile: prRel, + baseFile: baseOk ? baseRel : null, + diffFile: mismatched > 0 ? diffRel : null, + mismatched: mismatched > 0 ? mismatched : 0, + storyUrl: `/iframe.html?id=${story.id}&viewMode=story`, + status, + }); + } + } +} finally { + await browser.close(); + server.close(); +} + +const changedStories = [ + ...new Set(manifest.filter((m) => m.status === 'changed').map((m) => m.story)), +].sort(); +const newStories = [ + ...new Set(manifest.filter((m) => m.status === 'new').map((m) => m.story)), +].sort(); +const unchanged = manifest.filter((m) => m.status === 'unchanged').length; +const summary = { + total: manifest.length, + unchanged, + changed: changedStories.length, + new: newStories.length, + changedStories, + newStories, + hasDiffs: changedStories.length > 0 || newStories.length > 0, +}; + +await writeFile( + path.join(outDir, 'manifest.json'), + `${JSON.stringify( + { + baseLabel: skipBase ? null : baseLabel, + baselinesDir: skipBase ? null : baselinesDir, + summary, + stories: manifest, + }, + null, + 2, + )}\n`, + 'utf8', +); +await writeFile(path.join(outDir, 'index.html'), buildReportHtml(manifest, { baseLabel })); + +console.log( + `Visual screenshots: ${summary.total} comparisons → ${outDir} (${summary.changed} changed, ${summary.new} new). Open index.html`, +); diff --git a/packages/post-kit-editor/visual-baselines/admin-email-builder-blocks-text-default-desktop.png b/packages/post-kit-editor/visual-baselines/admin-email-builder-blocks-text-default-desktop.png new file mode 100644 index 0000000..e06b638 Binary files /dev/null and b/packages/post-kit-editor/visual-baselines/admin-email-builder-blocks-text-default-desktop.png differ diff --git a/packages/post-kit-editor/visual-baselines/admin-email-builder-overview-full-admin-desktop.png b/packages/post-kit-editor/visual-baselines/admin-email-builder-overview-full-admin-desktop.png new file mode 100644 index 0000000..dd61b4a Binary files /dev/null and b/packages/post-kit-editor/visual-baselines/admin-email-builder-overview-full-admin-desktop.png differ diff --git a/packages/post-kit-editor/visual-baselines/admin-email-builder-templates-welcome-editor-desktop.png b/packages/post-kit-editor/visual-baselines/admin-email-builder-templates-welcome-editor-desktop.png new file mode 100644 index 0000000..97ecc03 Binary files /dev/null and b/packages/post-kit-editor/visual-baselines/admin-email-builder-templates-welcome-editor-desktop.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 887c58f..567be9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,18 @@ importers: packages/post-kit-editor: dependencies: + '@emotion/react': + specifier: 11.11.3 + version: 11.11.3(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': + specifier: 11.11.0 + version: 11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/icons-material': + specifier: 5.15.10 + version: 5.15.10(@mui/material@5.15.10(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/material': + specifier: 5.15.10 + version: 5.15.10(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@singleton-sd/post-kit-compiler': specifier: workspace:* version: link:../post-kit-compiler @@ -258,19 +270,40 @@ importers: '@usewaypoint/email-builder': specifier: 0.0.9 version: 0.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.76) + highlight.js: + specifier: 11.9.0 + version: 11.9.0 + prettier: + specifier: 3.2.5 + version: 3.2.5 + react-colorful: + specifier: 5.6.1 + version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: specifier: ^3.25.76 version: 3.25.76 + zustand: + specifier: 4.5.1 + version: 4.5.1(@types/react@18.3.31)(react@18.3.1) devDependencies: + '@storybook/addon-essentials': + specifier: 8.6.14 + version: 8.6.14(@types/react@18.3.31)(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-interactions': + specifier: 8.6.14 + version: 8.6.14(storybook@8.6.14(prettier@3.2.5)) '@storybook/blocks': specifier: 8.6.14 - version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6)) + version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5)) '@storybook/react': specifier: 8.6.14 - version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6))(typescript@5.9.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5))(typescript@5.9.3) '@storybook/react-vite': specifier: 8.6.14 - version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.63.1)(storybook@8.6.14(prettier@3.9.6))(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0)) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.63.1)(storybook@8.6.14(prettier@3.2.5))(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0)) + '@storybook/test': + specifier: 8.6.14 + version: 8.6.14(storybook@8.6.14(prettier@3.2.5)) '@types/node': specifier: ^20.17.9 version: 20.19.43 @@ -280,6 +313,15 @@ importers: '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.31) + pixelmatch: + specifier: ^7.1.0 + version: 7.2.0 + playwright: + specifier: ^1.55.0 + version: 1.63.0 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -288,7 +330,7 @@ importers: version: 18.3.1(react@18.3.1) storybook: specifier: 8.6.14 - version: 8.6.14(prettier@3.9.6) + version: 8.6.14(prettier@3.2.5) tsx: specifier: ^4.19.2 version: 4.23.12 @@ -354,6 +396,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@azure-rest/core-client@2.8.0': resolution: {integrity: sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==} engines: {node: '>=22.0.0'} @@ -512,6 +557,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -536,6 +585,63 @@ packages: conventional-commits-parser: optional: true + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.11.3': + resolution: {integrity: sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.11.0': + resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.3.1': + resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -886,6 +992,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hono/node-server@2.1.1': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} @@ -1078,6 +1199,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + '@modelcontextprotocol/sdk@1.30.0': resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} @@ -1088,6 +1215,114 @@ packages: '@cfworker/json-schema': optional: true + '@mui/base@5.0.0-beta.36': + resolution: {integrity: sha512-6A8fYiXgjqTO6pgj31Hc8wm1M3rFYCxDRh09dBVk0L0W4cb2lnurRJa3cAyic6hHY+we1S58OdGYRbKmOsDpGQ==} + engines: {node: '>=12.0.0'} + deprecated: This package has been replaced by @base-ui/react + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/core-downloads-tracker@5.18.0': + resolution: {integrity: sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==} + + '@mui/icons-material@5.15.10': + resolution: {integrity: sha512-9cF8oUHZKo9oQ7EQ3pxPELaZuZVmphskU4OI6NiJNDVN7zcuvrEsuWjYo1Zh4fLiC39Nrvm30h/B51rcUjvSGA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material@5.15.10': + resolution: {integrity: sha512-YJJGHjwDOucecjDEV5l9ISTCo+l9YeWrho623UajzoHRYxuKUmwrGVYOW4PKwGvCx9SU9oklZnbbi2Clc5XZHw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@5.17.1': + resolution: {integrity: sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@5.18.0': + resolution: {integrity: sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@5.18.0': + resolution: {integrity: sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/types@7.4.12': + resolution: {integrity: sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@5.17.1': + resolution: {integrity: sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -1183,6 +1418,9 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@release-it/conventional-changelog@10.0.6': resolution: {integrity: sha512-aUb0IkcsBTMcOH5PPQ9Jv9lEOOVu2+rSgkE1ny+dzsTziQm2BhDRAtaFK/dw/HflthuXMWrqhhyfJhAV1AOEPQ==} engines: {node: ^20.12.0 || >=22.0.0} @@ -1346,6 +1584,61 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@storybook/addon-actions@8.6.14': + resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-backgrounds@8.6.14': + resolution: {integrity: sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-controls@8.6.14': + resolution: {integrity: sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-docs@8.6.14': + resolution: {integrity: sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-essentials@8.6.14': + resolution: {integrity: sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-highlight@8.6.14': + resolution: {integrity: sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-interactions@8.6.14': + resolution: {integrity: sha512-8VmElhm2XOjh22l/dO4UmXxNOolGhNiSpBcls2pqWSraVh4a670EyYBZsHpkXqfNHo2YgKyZN3C91+9zfH79qQ==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-measure@8.6.14': + resolution: {integrity: sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-outline@8.6.14': + resolution: {integrity: sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-toolbars@8.6.14': + resolution: {integrity: sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==} + peerDependencies: + storybook: ^8.6.14 + + '@storybook/addon-viewport@8.6.14': + resolution: {integrity: sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==} + peerDependencies: + storybook: ^8.6.14 + '@storybook/blocks@8.6.14': resolution: {integrity: sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==} peerDependencies: @@ -1392,6 +1685,11 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + '@storybook/instrumenter@8.6.14': + resolution: {integrity: sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==} + peerDependencies: + storybook: ^8.6.14 + '@storybook/manager-api@8.6.14': resolution: {integrity: sha512-ez0Zihuy17udLbfHZQXkGqwtep0mSGgHcNzGN7iZrMP1m+VmNo+7aGCJJdvXi7+iU3yq8weXSQFWg5DqWgLS7g==} peerDependencies: @@ -1437,14 +1735,36 @@ packages: typescript: optional: true + '@storybook/test@8.6.14': + resolution: {integrity: sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==} + peerDependencies: + storybook: ^8.6.14 + '@storybook/theming@8.6.14': resolution: {integrity: sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.5.0': + resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1466,6 +1786,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} @@ -1478,6 +1801,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/parse-path@7.1.0': resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. @@ -1490,12 +1816,20 @@ packages: peerDependencies: '@types/react': ^18.0.0 + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -1573,6 +1907,24 @@ packages: react-dom: ^16 || ^17 || ^18 zod: ^1 || ^2 || ^3 + '@vitest/expect@2.0.5': + resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + + '@vitest/pretty-format@2.0.5': + resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/spy@2.0.5': + resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + + '@vitest/utils@2.0.5': + resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1624,6 +1976,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1634,9 +1990,20 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + assignment@2.0.0: resolution: {integrity: sha512-naMULXjtgCs9SVUEtyvJNt68aF18em7/W+dhbR59kbz9cXWPEvUkCun2tqlgqRPSqZaKPpqLc5ZnwL8jVmJRvw==} @@ -1658,6 +2025,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1740,6 +2111,14 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1751,6 +2130,10 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1771,6 +2154,10 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1843,6 +2230,9 @@ packages: engines: {node: '>=18'} hasBin: true + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1858,6 +2248,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -1871,6 +2265,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1887,6 +2284,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1922,10 +2323,23 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -2064,6 +2478,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2159,6 +2576,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2306,6 +2726,13 @@ packages: resolution: {integrity: sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==} hasBin: true + highlight.js@11.9.0: + resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} + engines: {node: '>=12.0.0'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.13.7: resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} @@ -2355,6 +2782,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2643,6 +3074,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2653,6 +3087,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macos-release@3.5.1: resolution: {integrity: sha512-Lci/1in+elqZ589PXnfP/iwZXpwQifTM94WJRQwG2tZSdfY7NfB/aUaTARHrohWCgHlXoabeaeXRDOnF5X9JQw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2664,6 +3102,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + map-or-similar@1.5.0: + resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} + marked@12.0.2: resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} engines: {node: '>= 18'} @@ -2677,6 +3118,9 @@ packages: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} + memoizerific@1.11.3: + resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -2720,6 +3164,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2899,10 +3347,18 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + path-type@5.0.0: resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} engines: {node: '>=12'} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2914,10 +3370,32 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + polished@4.3.1: + resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} + engines: {node: '>=10'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2930,11 +3408,20 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -2943,6 +3430,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -2987,6 +3477,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -3001,6 +3497,24 @@ packages: peerDependencies: react: ^18.3.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3017,6 +3531,10 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + registry-auth-token@5.1.1: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} engines: {node: '>=14'} @@ -3173,6 +3691,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -3245,6 +3767,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-indent@4.1.1: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} @@ -3266,6 +3792,9 @@ packages: stubborn-utils@1.0.2: resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3285,6 +3814,14 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3392,12 +3929,22 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-sync-external-store@1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3524,6 +4071,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3553,8 +4104,25 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@4.5.1: + resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: + '@adobe/css-tools@4.5.0': {} + '@azure-rest/core-client@2.8.0': dependencies: '@azure/abort-controller': 2.2.0 @@ -3848,6 +4416,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3880,6 +4450,91 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.29.7 + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.3.1 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.11.3(@types/react@18.3.31)(react@18.3.1) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.3.1': {} + + '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -4082,6 +4737,23 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.12': {} + '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -4266,6 +4938,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@mdx-js/react@3.1.1(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@types/mdx': 2.0.14 + '@types/react': 18.3.31 + react: 18.3.1 + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': dependencies: '@hono/node-server': 2.1.1(hono@4.13.7) @@ -4288,6 +4966,110 @@ snapshots: transitivePeerDependencies: - supports-color + '@mui/base@5.0.0-beta.36(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/types': 7.4.12(@types/react@18.3.31) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@popperjs/core': 2.11.8 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + + '@mui/core-downloads-tracker@5.18.0': {} + + '@mui/icons-material@5.15.10(@mui/material@5.15.10(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/material': 5.15.10(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@mui/material@5.15.10(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/base': 5.0.0-beta.36(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/core-downloads-tracker': 5.18.0 + '@mui/system': 5.18.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@mui/types': 7.4.12(@types/react@18.3.31) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@types/react-transition-group': 4.4.12(@types/react@18.3.31) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + '@emotion/react': 11.11.3(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + + '@mui/private-theming@5.17.1(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@mui/styled-engine@5.18.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.11.3(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + + '@mui/system@5.18.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/private-theming': 5.17.1(@types/react@18.3.31)(react@18.3.1) + '@mui/styled-engine': 5.18.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.24(@types/react@18.3.31) + '@mui/utils': 5.17.1(@types/react@18.3.31)(react@18.3.1) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.11.3(@types/react@18.3.31)(react@18.3.1) + '@emotion/styled': 11.11.0(@emotion/react@11.11.3(@types/react@18.3.31)(react@18.3.1))(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + + '@mui/types@7.2.24(@types/react@18.3.31)': + optionalDependencies: + '@types/react': 18.3.31 + + '@mui/types@7.4.12(@types/react@18.3.31)': + dependencies: + '@babel/runtime': 7.29.7 + optionalDependencies: + '@types/react': 18.3.31 + + '@mui/utils@5.17.1(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/types': 7.2.24(@types/react@18.3.31) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.3.0 + optionalDependencies: + '@types/react': 18.3.31 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -4388,6 +5170,8 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@popperjs/core@2.11.8': {} + '@release-it/conventional-changelog@10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@18.1.2(@types/node@26.2.0)(typescript@5.9.3))': dependencies: '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) @@ -4499,30 +5283,117 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@storybook/blocks@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6))': + '@storybook/addon-actions@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + '@types/uuid': 9.0.8 + dequal: 2.0.3 + polished: 4.3.1 + storybook: 8.6.14(prettier@3.2.5) + uuid: 9.0.1 + + '@storybook/addon-backgrounds@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + + '@storybook/addon-controls@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + dequal: 2.0.3 + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + + '@storybook/addon-docs@8.6.14(@types/react@18.3.31)(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@18.3.31)(react@18.3.1) + '@storybook/blocks': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5)) + '@storybook/csf-plugin': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5)) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + transitivePeerDependencies: + - '@types/react' + + '@storybook/addon-essentials@8.6.14(@types/react@18.3.31)(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/addon-actions': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-backgrounds': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-controls': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-docs': 8.6.14(@types/react@18.3.31)(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-highlight': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-measure': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-outline': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-toolbars': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/addon-viewport': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + transitivePeerDependencies: + - '@types/react' + + '@storybook/addon-highlight@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.6.14(prettier@3.2.5) + + '@storybook/addon-interactions@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + polished: 4.3.1 + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + + '@storybook/addon-measure@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.6.14(prettier@3.2.5) + tiny-invariant: 1.3.3 + + '@storybook/addon-outline@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.6.14(prettier@3.2.5) + ts-dedent: 2.3.0 + + '@storybook/addon-toolbars@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + storybook: 8.6.14(prettier@3.2.5) + + '@storybook/addon-viewport@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + memoizerific: 1.11.3 + storybook: 8.6.14(prettier@3.2.5) + + '@storybook/blocks@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5))': dependencies: '@storybook/icons': 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) ts-dedent: 2.3.0 optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.6.14(storybook@8.6.14(prettier@3.9.6))(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0))': + '@storybook/builder-vite@8.6.14(storybook@8.6.14(prettier@3.2.5))(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 8.6.14(storybook@8.6.14(prettier@3.9.6)) + '@storybook/csf-plugin': 8.6.14(storybook@8.6.14(prettier@3.2.5)) browser-assert: 1.2.1 - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) ts-dedent: 2.3.0 vite: 6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0) - '@storybook/components@8.6.14(storybook@8.6.14(prettier@3.9.6))': + '@storybook/components@8.6.14(storybook@8.6.14(prettier@3.2.5))': dependencies: - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) - '@storybook/core@8.6.14(prettier@3.9.6)(storybook@8.6.14(prettier@3.9.6))': + '@storybook/core@8.6.14(prettier@3.2.5)(storybook@8.6.14(prettier@3.2.5))': dependencies: - '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.9.6)) + '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.2.5)) better-opn: 3.0.2 browser-assert: 1.2.1 esbuild: 0.25.12 @@ -4534,16 +5405,16 @@ snapshots: util: 0.12.5 ws: 8.21.3 optionalDependencies: - prettier: 3.9.6 + prettier: 3.2.5 transitivePeerDependencies: - bufferutil - storybook - supports-color - utf-8-validate - '@storybook/csf-plugin@8.6.14(storybook@8.6.14(prettier@3.9.6))': + '@storybook/csf-plugin@8.6.14(storybook@8.6.14(prettier@3.2.5))': dependencies: - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) unplugin: 1.16.1 '@storybook/global@5.0.0': {} @@ -4553,60 +5424,107 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/manager-api@8.6.14(storybook@8.6.14(prettier@3.9.6))': + '@storybook/instrumenter@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + '@vitest/utils': 2.1.9 + storybook: 8.6.14(prettier@3.2.5) + + '@storybook/manager-api@8.6.14(storybook@8.6.14(prettier@3.2.5))': dependencies: - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) - '@storybook/preview-api@8.6.14(storybook@8.6.14(prettier@3.9.6))': + '@storybook/preview-api@8.6.14(storybook@8.6.14(prettier@3.2.5))': dependencies: - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) - '@storybook/react-dom-shim@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6))': + '@storybook/react-dom-shim@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) - '@storybook/react-vite@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.63.1)(storybook@8.6.14(prettier@3.9.6))(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0))': + '@storybook/react-vite@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.63.1)(storybook@8.6.14(prettier@3.2.5))(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.63.1) - '@storybook/builder-vite': 8.6.14(storybook@8.6.14(prettier@3.9.6))(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0)) - '@storybook/react': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6))(typescript@5.9.3) + '@storybook/builder-vite': 8.6.14(storybook@8.6.14(prettier@3.2.5))(vite@6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0)) + '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5))(typescript@5.9.3) find-up: 5.0.0 magic-string: 0.30.21 react: 18.3.1 react-docgen: 7.1.1 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.12 - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) tsconfig-paths: 4.2.0 vite: 6.3.5(@types/node@20.19.43)(tsx@4.23.12)(yaml@2.9.0) + optionalDependencies: + '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.2.5)) transitivePeerDependencies: - rollup - supports-color - typescript - '@storybook/react@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6))(typescript@5.9.3)': + '@storybook/react@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5))(typescript@5.9.3)': dependencies: - '@storybook/components': 8.6.14(storybook@8.6.14(prettier@3.9.6)) + '@storybook/components': 8.6.14(storybook@8.6.14(prettier@3.2.5)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.6.14(storybook@8.6.14(prettier@3.9.6)) - '@storybook/preview-api': 8.6.14(storybook@8.6.14(prettier@3.9.6)) - '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.9.6)) - '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.9.6)) + '@storybook/manager-api': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/preview-api': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.2.5)) + '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.2.5)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.9.6) + storybook: 8.6.14(prettier@3.2.5) optionalDependencies: + '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.2.5)) typescript: 5.9.3 - '@storybook/theming@8.6.14(storybook@8.6.14(prettier@3.9.6))': + '@storybook/test@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.2.5)) + '@testing-library/dom': 10.4.0 + '@testing-library/jest-dom': 6.5.0 + '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) + '@vitest/expect': 2.0.5 + '@vitest/spy': 2.0.5 + storybook: 8.6.14(prettier@3.2.5) + + '@storybook/theming@8.6.14(storybook@8.6.14(prettier@3.2.5))': + dependencies: + storybook: 8.6.14(prettier@3.2.5) + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.5.0': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: - storybook: 8.6.14(prettier@3.9.6) + '@testing-library/dom': 10.4.0 '@tootallnate/quickjs-emscripten@0.23.0': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -4634,6 +5552,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mdx@2.0.14': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 @@ -4648,6 +5568,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/parse-json@4.0.2': {} + '@types/parse-path@7.1.0': dependencies: parse-path: 7.1.0 @@ -4658,6 +5580,10 @@ snapshots: dependencies: '@types/react': 18.3.31 + '@types/react-transition-group@4.4.12(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -4665,6 +5591,8 @@ snapshots: '@types/resolve@1.20.6': {} + '@types/uuid@9.0.8': {} + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -4747,6 +5675,38 @@ snapshots: react-dom: 18.3.1(react@18.3.1) zod: 3.25.76 + '@vitest/expect@2.0.5': + dependencies: + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@2.0.5': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/spy@2.0.5': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.0.5': + dependencies: + '@vitest/pretty-format': 2.0.5 + estree-walker: 3.0.3 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4794,14 +5754,24 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} anynum@1.0.1: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-ify@1.0.0: {} + assertion-error@2.0.1: {} + assignment@2.0.0: {} ast-types@0.13.4: @@ -4825,6 +5795,12 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.7 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + balanced-match@1.0.2: {} baseline-browser-mapping@2.11.21: {} @@ -4918,6 +5894,19 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4927,6 +5916,8 @@ snapshots: chardet@2.2.0: {} + check-error@2.1.3: {} + ci-info@4.4.0: {} cli-boxes@3.0.0: {} @@ -4939,6 +5930,8 @@ snapshots: cli-width@4.1.0: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5024,6 +6017,8 @@ snapshots: conventional-commits-parser: 6.4.0 meow: 13.2.0 + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -5035,6 +6030,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -5050,6 +6053,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + csstype@3.2.3: {} data-uri-to-buffer@6.0.2: {} @@ -5058,6 +6063,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -5087,10 +6094,21 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -5286,6 +6304,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -5433,6 +6455,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-root@1.1.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5589,6 +6613,12 @@ snapshots: he@0.5.0: {} + highlight.js@11.9.0: {} + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + hono@4.13.7: {} hosted-git-info@8.1.0: @@ -5636,6 +6666,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -5888,6 +6920,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -5896,6 +6930,8 @@ snapshots: lru-cache@7.18.3: {} + lz-string@1.5.0: {} + macos-release@3.5.1: {} magic-string@0.27.0: @@ -5906,12 +6942,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + map-or-similar@1.5.0: {} + marked@12.0.2: {} math-intrinsics@1.1.0: {} media-typer@1.1.1: {} + memoizerific@1.11.3: + dependencies: + map-or-similar: 1.5.0 + meow@13.2.0: {} merge-descriptors@2.0.0: {} @@ -5941,6 +6983,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.18 @@ -6127,16 +7171,36 @@ snapshots: path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + path-type@5.0.0: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.5: {} + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + pkce-challenge@5.0.1: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + pngjs@7.0.0: {} + + polished@4.3.1: + dependencies: + '@babel/runtime': 7.29.7 + possible-typed-array-names@1.1.0: {} postcss@8.5.28: @@ -6147,14 +7211,28 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.2.5: {} + prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 process@0.11.10: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proto-list@1.2.4: {} protocols@2.0.2: {} @@ -6208,6 +7286,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6233,6 +7316,23 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react-is@19.3.0: {} + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -6255,6 +7355,11 @@ snapshots: dependencies: resolve: 1.22.12 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + registry-auth-token@5.1.1: dependencies: '@pnpm/npm-conf': 3.0.3 @@ -6486,6 +7591,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.5.7: {} + source-map@0.6.1: {} spdx-correct@3.2.0: @@ -6506,11 +7613,11 @@ snapshots: stdin-discarder@0.2.2: {} - storybook@8.6.14(prettier@3.9.6): + storybook@8.6.14(prettier@3.2.5): dependencies: - '@storybook/core': 8.6.14(prettier@3.9.6)(storybook@8.6.14(prettier@3.9.6)) + '@storybook/core': 8.6.14(prettier@3.2.5)(storybook@8.6.14(prettier@3.2.5)) optionalDependencies: - prettier: 3.9.6 + prettier: 3.2.5 transitivePeerDependencies: - bufferutil - supports-color @@ -6554,6 +7661,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-indent@4.1.1: {} strip-json-comments@2.0.1: {} @@ -6570,6 +7681,8 @@ snapshots: stubborn-utils@1.0.2: {} + stylis@4.2.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6585,6 +7698,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6676,6 +7793,10 @@ snapshots: url-join@5.0.0: {} + use-sync-external-store@1.2.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} util@0.12.5: @@ -6686,6 +7807,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.22 + uuid@9.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -6775,6 +7898,8 @@ snapshots: yallist@3.1.1: {} + yaml@1.10.3: {} + yaml@2.9.0: optional: true @@ -6791,3 +7916,10 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zustand@4.5.1(@types/react@18.3.31)(react@18.3.1): + dependencies: + use-sync-external-store: 1.2.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.1