diff --git a/examples/experience-auditor/.gitignore b/examples/experience-auditor/.gitignore
new file mode 100644
index 0000000000..d258ba0341
--- /dev/null
+++ b/examples/experience-auditor/.gitignore
@@ -0,0 +1,24 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# dotenv environment variables file
+.env
+.env.*
+!.env*.example
+
+# misc
+.DS_Store
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/examples/experience-auditor/README.md b/examples/experience-auditor/README.md
new file mode 100644
index 0000000000..7b2c96e84f
--- /dev/null
+++ b/examples/experience-auditor/README.md
@@ -0,0 +1,123 @@
+# Experience Auditor
+
+A polished, real-world example app for the **Experience Editor toolbar** — the
+`experience-toolbar` location introduced in
+[`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk).
+
+Experience Auditor runs alongside the Experience Orchestration (ExO) editor and
+continuously audits the experience you are editing for **accessibility, SEO, and
+content-completeness** issues. It demonstrates the standout capability of the
+toolbar location: **live, selection-aware tooling that reads _and_ mutates the
+experience tree as the author works.**
+
+> Looking for the bare-bones starter instead? See the
+> [`experience-toolbar`](../experience-toolbar) example, which demonstrates the
+> minimal `sdk.exo` patterns. Experience Auditor builds on those to show a
+> complete, opinionated app.
+
+## What it does
+
+- **Live audit** — walks the experience tree with `getRootNodes()` →
+ `getNode()` → `getProperties()`, runs a set of pure rules, and re-runs
+ automatically on `sdk.exo.experience.onChange()`.
+- **Scored dashboard** — a 0–100 health score with error / warning / info
+ counts.
+- **Locate on canvas** — clicking **Locate** calls
+ `selection.set(nodeId)` + `selection.highlight(nodeId, { flash, scrollIntoView })`
+ to jump straight to the offending component (visual mode only).
+- **One-click fixes** — where a safe, deterministic fix exists (e.g. trimming
+ stray whitespace from alt text), the app applies it via
+ `getNode().setContentProperty()`, permission-checked with `sdk.access.can()`
+ and confirmed through `sdk.notifier`.
+- **Pre-publish gate** — `experience.publish()` is blocked while any error-level
+ finding remains.
+
+### Audit rules
+
+| Rule | Severity | What it checks |
+| --- | --- | --- |
+| `a11y/image-alt-text` | error / warning | Images must have non-empty alt text; trims stray whitespace |
+| `content/required-empty` | warning | Headings/titles should not be empty |
+| `seo/missing-meta` | info | SEO meta fields should be populated |
+| `content/broken-binding` | error | Entry-bound properties must resolve to an entry |
+
+The rules live in [`src/audit/rules.ts`](src/audit/rules.ts) as pure functions
+over a SDK-independent `CollectedNode` shape, so they are fully unit-tested
+without a live SDK. Adding a rule is a matter of dropping another `AuditRule`
+into `AUDIT_RULES`.
+
+## Architecture
+
+```
+src/
+ audit/
+ types.ts SDK-independent domain types (CollectedNode, AuditFinding, …)
+ rules.ts Pure audit rules
+ engine.ts Runs rules, aggregates findings, computes the score
+ collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[]
+ components/
+ ScoreSummary.tsx
+ FindingList.tsx
+ locations/
+ ConfigScreen.tsx
+ ExperienceToolbar.tsx Wires the SDK to the engine (collect → audit → locate/fix/publish)
+```
+
+Keeping the rules pure and the SDK boundary thin (`collect.ts`) is the key
+pattern: all the interesting logic is testable in isolation, and the live SDK
+work is small enough to reason about.
+
+## How to use
+
+```bash
+# npx
+npx create-contentful-app --example experience-auditor
+
+# npm
+npm init contentful-app -- --example experience-auditor
+
+# Yarn
+yarn create contentful-app --example experience-auditor
+```
+
+Then:
+
+```bash
+npm install
+npm start
+```
+
+## Registering the toolbar location
+
+Like other toolbar apps, this is **not** assigned per content type — there is no
+`EditorInterface` target state. It renders whenever the `experience-toolbar`
+location is registered on your app definition. Create one with:
+
+```bash
+npm run create-app-definition
+```
+
+selecting the **App configuration screen** and **Experience toolbar** locations,
+pointing the app at `http://localhost:3000`.
+
+## A note on verification
+
+This app is built against the published `@contentful/app-sdk@4.58.0` types,
+which are the contract for the toolbar location. At the time of writing the host
+renderer that serves `sdk.exo` at runtime is still rolling out, so the app is
+**type-verified and unit-tested against a mocked SDK** (audit rules, scoring,
+collector, and the toolbar's locate/fix/publish-gate behavior all have tests),
+but not yet verified end-to-end inside a live ExO editor. The API shapes used
+here match the published types exactly.
+
+## Available scripts
+
+- `npm start` — run in development mode
+- `npm run build` — production build to `build/`
+- `npm run test:ci` — run the test suite once
+- `npm run upload` / `npm run upload-ci` — deploy the bundle to Contentful
+
+## Libraries
+
+- [Forma 36](https://f36.contentful.com/) — Contentful's design system
+- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) — the `sdk.exo` reference
diff --git a/examples/experience-auditor/index.html b/examples/experience-auditor/index.html
new file mode 100644
index 0000000000..cf65f5e579
--- /dev/null
+++ b/examples/experience-auditor/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/experience-auditor/package.json b/examples/experience-auditor/package.json
new file mode 100644
index 0000000000..4383fc531f
--- /dev/null
+++ b/examples/experience-auditor/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "experience-auditor-example",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@contentful/app-sdk": "4.58.0",
+ "@contentful/f36-components": "4.81.1",
+ "@contentful/f36-icons": "^4.28.0",
+ "@contentful/f36-tokens": "4.2.0",
+ "@contentful/react-apps-toolkit": "1.2.16",
+ "emotion": "10.0.27",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:ci": "vitest run",
+ "create-app-definition": "contentful-app-scripts create-app-definition",
+ "add-locations": "contentful-app-scripts add-locations",
+ "upload": "contentful-app-scripts upload --bundle-dir ./build",
+ "upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN"
+ },
+ "eslintConfig": {
+ "extends": "react-app"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@contentful/app-scripts": "^2.3.0",
+ "@testing-library/jest-dom": "^5.17.0",
+ "@testing-library/react": "^14.3.1",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^22.13.5",
+ "@types/react": "18.3.13",
+ "@types/react-dom": "18.3.1",
+ "@vitejs/plugin-react": "^4.0.3",
+ "cross-env": "7.0.3",
+ "jsdom": "^26.0.0",
+ "typescript": "4.9.5",
+ "vite": "^6.2.2",
+ "vitest": "^3.0.9"
+ },
+ "homepage": "."
+}
diff --git a/examples/experience-auditor/src/App.tsx b/examples/experience-auditor/src/App.tsx
new file mode 100644
index 0000000000..5db9499a33
--- /dev/null
+++ b/examples/experience-auditor/src/App.tsx
@@ -0,0 +1,27 @@
+import React, { useMemo } from 'react';
+import { locations } from '@contentful/app-sdk';
+import { useSDK } from '@contentful/react-apps-toolkit';
+
+import ConfigScreen from './locations/ConfigScreen';
+import ExperienceToolbar from './locations/ExperienceToolbar';
+
+const ComponentLocationSettings = {
+ [locations.LOCATION_APP_CONFIG]: ConfigScreen,
+ [locations.LOCATION_EXPERIENCE_TOOLBAR]: ExperienceToolbar,
+};
+
+const App = () => {
+ const sdk = useSDK();
+
+ const Component = useMemo(() => {
+ for (const [location, component] of Object.entries(ComponentLocationSettings)) {
+ if (sdk.location.is(location)) {
+ return component;
+ }
+ }
+ }, [sdk.location]);
+
+ return Component ? : null;
+};
+
+export default App;
diff --git a/examples/experience-auditor/src/audit/audit.spec.ts b/examples/experience-auditor/src/audit/audit.spec.ts
new file mode 100644
index 0000000000..0a1491cb6f
--- /dev/null
+++ b/examples/experience-auditor/src/audit/audit.spec.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from 'vitest';
+import type { ComponentPropertyDescriptor } from '@contentful/app-sdk';
+import { computeScore, hasBlockingErrors, runAudit } from './engine';
+import { AUDIT_RULES } from './rules';
+import type { CollectedNode } from './types';
+
+function node(
+ id: string,
+ properties: ComponentPropertyDescriptor[]
+): CollectedNode {
+ return { id, nodeType: 'Component', properties };
+}
+
+describe('audit rules', () => {
+ it('flags an image with no alt text as an error', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'image', area: 'content', value: { sys: { id: 'a1' } } },
+ { key: 'altText', area: 'content', value: '' },
+ ]),
+ ]);
+
+ const finding = report.findings.find((f) => f.ruleId === 'a11y/image-alt-text');
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe('error');
+ });
+
+ it('does not flag an image that has alt text', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'image', area: 'content', value: { sys: { id: 'a1' } } },
+ { key: 'altText', area: 'content', value: 'A hero image' },
+ ]),
+ ]);
+
+ expect(report.findings.find((f) => f.ruleId === 'a11y/image-alt-text')).toBeUndefined();
+ });
+
+ it('offers a trim fix when alt text has surrounding whitespace', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'image', area: 'content', value: { sys: { id: 'a1' } } },
+ { key: 'altText', area: 'content', value: ' spaced ' },
+ ]),
+ ]);
+
+ const finding = report.findings.find((f) => f.ruleId === 'a11y/image-alt-text');
+ expect(finding?.severity).toBe('warning');
+ expect(finding?.fix).toEqual({
+ label: 'Trim whitespace',
+ propertyKey: 'altText',
+ value: 'spaced',
+ });
+ });
+
+ it('does not flag a node without an image', () => {
+ const report = runAudit([node('text', [{ key: 'body', area: 'content', value: 'Hi' }])]);
+ expect(report.findings.filter((f) => f.ruleId === 'a11y/image-alt-text')).toHaveLength(0);
+ });
+
+ it('flags an empty heading as a warning', () => {
+ const report = runAudit([node('cta', [{ key: 'heading', area: 'content', value: '' }])]);
+ const finding = report.findings.find((f) => f.ruleId === 'content/required-empty');
+ expect(finding?.severity).toBe('warning');
+ });
+
+ it('flags empty SEO metadata as info', () => {
+ const report = runAudit([
+ node('page', [{ key: 'metaDescription', area: 'content', value: '' }]),
+ ]);
+ const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta');
+ expect(finding?.severity).toBe('info');
+ });
+
+ it('flags a broken entry binding as an error', () => {
+ const report = runAudit([
+ node('card', [
+ {
+ key: 'title',
+ area: 'content',
+ value: null,
+ binding: { sourceType: 'entry' },
+ },
+ ]),
+ ]);
+ const finding = report.findings.find((f) => f.ruleId === 'content/broken-binding');
+ expect(finding?.severity).toBe('error');
+ });
+
+ it('does not flag a resolved entry binding', () => {
+ const report = runAudit([
+ node('card', [
+ {
+ key: 'title',
+ area: 'content',
+ value: 'Bound',
+ binding: { sourceType: 'entry', entryId: 'entry-1' },
+ },
+ ]),
+ ]);
+ expect(report.findings.find((f) => f.ruleId === 'content/broken-binding')).toBeUndefined();
+ });
+
+ it('exposes a stable rule set', () => {
+ expect(AUDIT_RULES.map((r) => r.id)).toEqual([
+ 'a11y/image-alt-text',
+ 'content/required-empty',
+ 'seo/missing-meta',
+ 'content/broken-binding',
+ ]);
+ });
+});
+
+describe('scoring', () => {
+ it('scores a clean experience at 100', () => {
+ const report = runAudit([node('ok', [{ key: 'body', area: 'content', value: 'Hello' }])]);
+ expect(report.score).toBe(100);
+ expect(hasBlockingErrors(report)).toBe(false);
+ });
+
+ it('subtracts weighted penalties and clamps at zero', () => {
+ expect(computeScore([])).toBe(100);
+ expect(
+ computeScore([
+ { id: 'a', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'error', title: '', detail: '' },
+ { id: 'b', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'warning', title: '', detail: '' },
+ { id: 'c', ruleId: 'r', nodeId: 'n', nodeType: 'Component', severity: 'info', title: '', detail: '' },
+ ])
+ ).toBe(85); // 100 - (10 + 4 + 1)
+ });
+
+ it('reports blocking errors when any error is present', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'image', area: 'content', value: { sys: { id: 'a1' } } },
+ { key: 'altText', area: 'content', value: '' },
+ ]),
+ ]);
+ expect(hasBlockingErrors(report)).toBe(true);
+ });
+
+ it('sorts findings errors-first', () => {
+ const report = runAudit([
+ node('a', [{ key: 'metaTitle', area: 'content', value: '' }]), // info
+ node('b', [
+ { key: 'image', area: 'content', value: { sys: { id: 'x' } } },
+ { key: 'altText', area: 'content', value: '' }, // error
+ ]),
+ ]);
+ expect(report.findings[0].severity).toBe('error');
+ });
+});
diff --git a/examples/experience-auditor/src/audit/collect.spec.ts b/examples/experience-auditor/src/audit/collect.spec.ts
new file mode 100644
index 0000000000..2e850a391f
--- /dev/null
+++ b/examples/experience-auditor/src/audit/collect.spec.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from 'vitest';
+import { collectNodes } from './collect';
+import { makeMockNode } from '../../test/mocks';
+
+describe('collectNodes', () => {
+ it('resolves properties for every root node', async () => {
+ const experience: any = {
+ getRootNodes: vi.fn().mockReturnValue([
+ makeMockNode('a', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]),
+ makeMockNode('b', 'Component', [{ key: 'body', area: 'content', value: 'There' }]),
+ ]),
+ };
+
+ const collected = await collectNodes(experience);
+
+ expect(collected).toHaveLength(2);
+ expect(collected[0]).toMatchObject({ id: 'a', nodeType: 'Component' });
+ expect(collected[0].properties[0].key).toBe('heading');
+ });
+
+ it('skips nodes whose properties fail to resolve', async () => {
+ const broken = makeMockNode('broken', 'Component', []);
+ broken.getProperties = vi.fn().mockRejectedValue(new Error('gone'));
+
+ const experience: any = {
+ getRootNodes: vi
+ .fn()
+ .mockReturnValue([
+ broken,
+ makeMockNode('ok', 'Component', [{ key: 'heading', area: 'content', value: 'Hi' }]),
+ ]),
+ };
+
+ const collected = await collectNodes(experience);
+
+ expect(collected).toHaveLength(1);
+ expect(collected[0].id).toBe('ok');
+ });
+});
diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts
new file mode 100644
index 0000000000..f2e7272037
--- /dev/null
+++ b/examples/experience-auditor/src/audit/collect.ts
@@ -0,0 +1,34 @@
+import type { ExperienceAPI } from '@contentful/app-sdk';
+import type { CollectedNode } from './types';
+
+/**
+ * Walks the experience tree and resolves each node's properties into the
+ * SDK-independent {@link CollectedNode} shape the audit engine consumes.
+ *
+ * `getRootNodes()` returns the top-level nodes; this example audits those
+ * directly. A production app would additionally descend through slot
+ * descriptors (`getSlotDescriptor().currentItems`) to cover nested components —
+ * the same `getNode` + `getProperties` pattern, applied recursively.
+ */
+export async function collectNodes(experience: ExperienceAPI): Promise {
+ const roots = experience.getRootNodes();
+
+ const collected = await Promise.all(
+ roots.map(async (node) => {
+ try {
+ const properties = await node.getProperties();
+ return {
+ id: node.id,
+ nodeType: node.nodeType,
+ properties,
+ } satisfies CollectedNode;
+ } catch {
+ // A node may have been removed mid-traversal; skip it rather than
+ // failing the whole audit.
+ return null;
+ }
+ })
+ );
+
+ return collected.filter((node): node is CollectedNode => node !== null);
+}
diff --git a/examples/experience-auditor/src/audit/engine.ts b/examples/experience-auditor/src/audit/engine.ts
new file mode 100644
index 0000000000..2ab29209dc
--- /dev/null
+++ b/examples/experience-auditor/src/audit/engine.ts
@@ -0,0 +1,64 @@
+import type { AuditFinding, AuditReport, AuditRule, CollectedNode, Severity } from './types';
+import { AUDIT_RULES } from './rules';
+
+/** Penalty applied to the health score per finding, by severity. */
+const SEVERITY_WEIGHT: Record = {
+ error: 10,
+ warning: 4,
+ info: 1,
+};
+
+const EMPTY_COUNTS: Record = { error: 0, warning: 0, info: 0 };
+
+/**
+ * Runs every rule over every collected node and aggregates the findings into a
+ * report. Pure and synchronous — all async SDK work (resolving nodes and their
+ * properties) happens in the collector before this is called, which keeps the
+ * scoring logic trivially testable.
+ */
+export function runAudit(
+ nodes: CollectedNode[],
+ rules: AuditRule[] = AUDIT_RULES
+): AuditReport {
+ const findings: AuditFinding[] = [];
+
+ for (const node of nodes) {
+ for (const rule of rules) {
+ findings.push(...rule.evaluate(node));
+ }
+ }
+
+ const counts = { ...EMPTY_COUNTS };
+ for (const finding of findings) {
+ counts[finding.severity] += 1;
+ }
+
+ return {
+ findings: sortFindings(findings),
+ score: computeScore(findings),
+ counts,
+ nodeCount: nodes.length,
+ };
+}
+
+const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 };
+
+/** Errors first, then warnings, then info; stable within a severity. */
+function sortFindings(findings: AuditFinding[]): AuditFinding[] {
+ return [...findings].sort(
+ (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
+ );
+}
+
+/**
+ * A 0–100 health score. Each finding subtracts its severity weight; the score
+ * is clamped at 0. An experience with no findings scores 100.
+ */
+export function computeScore(findings: AuditFinding[]): number {
+ const penalty = findings.reduce((sum, f) => sum + SEVERITY_WEIGHT[f.severity], 0);
+ return Math.max(0, 100 - penalty);
+}
+
+export function hasBlockingErrors(report: AuditReport): boolean {
+ return report.counts.error > 0;
+}
diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts
new file mode 100644
index 0000000000..7a47801159
--- /dev/null
+++ b/examples/experience-auditor/src/audit/rules.ts
@@ -0,0 +1,181 @@
+import type { ComponentPropertyDescriptor } from '@contentful/app-sdk';
+import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types';
+
+/** Property keys are matched case-insensitively against these hints. */
+const IMAGE_KEY_HINT = /(image|photo|asset|media|thumbnail|icon|logo)/i;
+const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i;
+const HEADING_KEY_HINT = /(heading|title|headline)/i;
+const META_KEY_HINT = /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i;
+
+function findProperty(
+ node: CollectedNode,
+ matcher: RegExp
+): ComponentPropertyDescriptor | undefined {
+ return node.properties.find((p) => matcher.test(stripNonAlpha(p.key)));
+}
+
+function stripNonAlpha(key: string): string {
+ return key.replace(/[^a-z0-9]/gi, '');
+}
+
+function isEmptyValue(value: unknown): boolean {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim().length === 0;
+ if (Array.isArray(value)) return value.length === 0;
+ return false;
+}
+
+/** True when a property resolves to text the author actually authored. */
+function isContentText(property: ComponentPropertyDescriptor): property is ComponentPropertyDescriptor & { value: string } {
+ return property.area === 'content' && typeof property.value === 'string';
+}
+
+function makeFinding(
+ rule: Pick,
+ node: CollectedNode,
+ partial: {
+ propertyKey?: string;
+ severity: Severity;
+ title: string;
+ detail: string;
+ fix?: AuditFinding['fix'];
+ }
+): AuditFinding {
+ return {
+ id: `${rule.id}:${node.id}:${partial.propertyKey ?? ''}`,
+ ruleId: rule.id,
+ nodeId: node.id,
+ nodeType: node.nodeType,
+ ...partial,
+ };
+}
+
+/**
+ * Image properties must have accompanying alt text. Flags an image-like content
+ * property whose sibling alt-text property is empty or missing. Where the alt
+ * text exists but is only whitespace, offers a one-click trim fix.
+ */
+const altTextRule: AuditRule = {
+ id: 'a11y/image-alt-text',
+ description: 'Images should have non-empty alternative text.',
+ evaluate(node) {
+ const image = findProperty(node, IMAGE_KEY_HINT);
+ if (!image || isEmptyValue(image.value)) {
+ // No image set on this node — nothing to audit.
+ return [];
+ }
+
+ const alt = findProperty(node, ALT_KEY_HINT);
+
+ if (!alt || isEmptyValue(alt.value)) {
+ return [
+ makeFinding(altTextRule, node, {
+ propertyKey: alt?.key,
+ severity: 'error',
+ title: 'Image is missing alt text',
+ detail:
+ 'This component has an image but no alternative text. Screen readers cannot describe it.',
+ }),
+ ];
+ }
+
+ if (isContentText(alt) && alt.value !== alt.value.trim()) {
+ return [
+ makeFinding(altTextRule, node, {
+ propertyKey: alt.key,
+ severity: 'warning',
+ title: 'Alt text has surrounding whitespace',
+ detail: 'The alt text has leading or trailing whitespace.',
+ fix: {
+ label: 'Trim whitespace',
+ propertyKey: alt.key,
+ value: alt.value.trim(),
+ },
+ }),
+ ];
+ }
+
+ return [];
+ },
+};
+
+/**
+ * Required content properties must not be empty. The host marks a property as
+ * required via a convention on the descriptor; here we treat any empty content
+ * text property whose key looks like a heading/title as required, plus any
+ * property explicitly flagged. (Kept conservative to avoid false positives.)
+ */
+const requiredContentRule: AuditRule = {
+ id: 'content/required-empty',
+ description: 'Required content fields should not be empty.',
+ evaluate(node) {
+ const heading = findProperty(node, HEADING_KEY_HINT);
+ if (heading && heading.area === 'content' && isEmptyValue(heading.value)) {
+ return [
+ makeFinding(requiredContentRule, node, {
+ propertyKey: heading.key,
+ severity: 'warning',
+ title: 'Heading is empty',
+ detail: `"${heading.key}" has no value. Components usually need a heading to be useful.`,
+ }),
+ ];
+ }
+ return [];
+ },
+};
+
+/**
+ * SEO metadata should be present on the root of an experience. Flags a missing
+ * or empty meta description / title when the node exposes such a property.
+ */
+const seoMetaRule: AuditRule = {
+ id: 'seo/missing-meta',
+ description: 'SEO metadata should be populated.',
+ evaluate(node) {
+ const meta = findProperty(node, META_KEY_HINT);
+ if (meta && meta.area === 'content' && isEmptyValue(meta.value)) {
+ return [
+ makeFinding(seoMetaRule, node, {
+ propertyKey: meta.key,
+ severity: 'info',
+ title: 'SEO metadata is empty',
+ detail: `"${meta.key}" is empty. Populate it to improve search and social sharing.`,
+ }),
+ ];
+ }
+ return [];
+ },
+};
+
+/**
+ * Content properties bound to an entry must actually resolve. A binding whose
+ * source is an entry but with no entryId recorded is a broken reference.
+ */
+const brokenBindingRule: AuditRule = {
+ id: 'content/broken-binding',
+ description: 'Entry bindings should resolve to an entry.',
+ evaluate(node) {
+ const findings: AuditFinding[] = [];
+ for (const property of node.properties) {
+ const binding = property.binding;
+ if (binding && binding.sourceType === 'entry' && !binding.entryId) {
+ findings.push(
+ makeFinding(brokenBindingRule, node, {
+ propertyKey: property.key,
+ severity: 'error',
+ title: 'Broken entry binding',
+ detail: `"${property.key}" is bound to an entry, but the reference is missing or unresolved.`,
+ })
+ );
+ }
+ }
+ return findings;
+ },
+};
+
+export const AUDIT_RULES: AuditRule[] = [
+ altTextRule,
+ requiredContentRule,
+ seoMetaRule,
+ brokenBindingRule,
+];
diff --git a/examples/experience-auditor/src/audit/types.ts b/examples/experience-auditor/src/audit/types.ts
new file mode 100644
index 0000000000..b45f2ee03d
--- /dev/null
+++ b/examples/experience-auditor/src/audit/types.ts
@@ -0,0 +1,54 @@
+import type { ComponentPropertyDescriptor, ExoNodeType } from '@contentful/app-sdk';
+
+export type Severity = 'error' | 'warning' | 'info';
+
+/**
+ * A node collected from the experience tree, paired with its resolved
+ * properties. This is the SDK-independent shape the audit rules operate on, so
+ * the rules can be unit-tested without a live `sdk.exo`.
+ */
+export interface CollectedNode {
+ id: string;
+ nodeType: ExoNodeType;
+ properties: ComponentPropertyDescriptor[];
+}
+
+/**
+ * A deterministic, one-click fix for a finding. When present, the UI offers a
+ * "Fix" action that writes `value` to `propertyKey` via `setContentProperty`.
+ * Only safe, non-destructive transforms (e.g. trimming whitespace) carry a fix
+ * — we never invent content the author hasn't written.
+ */
+export interface AutoFix {
+ label: string;
+ propertyKey: string;
+ value: unknown;
+}
+
+export interface AuditFinding {
+ /** Stable key for React lists and de-duplication. */
+ id: string;
+ ruleId: string;
+ nodeId: string;
+ nodeType: ExoNodeType;
+ propertyKey?: string;
+ severity: Severity;
+ title: string;
+ detail: string;
+ fix?: AutoFix;
+}
+
+/** A pure audit rule: given one node, return zero or more findings. */
+export interface AuditRule {
+ id: string;
+ description: string;
+ evaluate(node: CollectedNode): AuditFinding[];
+}
+
+export interface AuditReport {
+ findings: AuditFinding[];
+ /** Overall health score, 0–100 (100 = no findings). */
+ score: number;
+ counts: Record;
+ nodeCount: number;
+}
diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx
new file mode 100644
index 0000000000..ab3ec33408
--- /dev/null
+++ b/examples/experience-auditor/src/components/FindingList.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { Badge, Box, Button, Flex, Note, Stack, Text } from '@contentful/f36-components';
+import type { BadgeProps } from '@contentful/f36-components';
+import type { AuditFinding, Severity } from '../audit/types';
+
+interface FindingListProps {
+ findings: AuditFinding[];
+ /** Whether the canvas supports highlighting (visual mode only). */
+ canLocate: boolean;
+ /** Whether the current user may write content properties. */
+ canFix: boolean;
+ onLocate: (finding: AuditFinding) => void;
+ onFix: (finding: AuditFinding) => void;
+ busyFindingId: string | null;
+}
+
+const SEVERITY_VARIANT: Record = {
+ error: 'negative',
+ warning: 'warning',
+ info: 'secondary',
+};
+
+const FindingList = ({
+ findings,
+ canLocate,
+ canFix,
+ onLocate,
+ onFix,
+ busyFindingId,
+}: FindingListProps) => {
+ if (findings.length === 0) {
+ return (
+
+ No issues found. This experience passes every audit rule. 🎉
+
+ );
+ }
+
+ return (
+
+ {findings.map((finding) => (
+
+
+
+
+ {finding.severity}
+ {finding.title}
+
+
+ {finding.detail}
+
+ {finding.propertyKey && (
+
+ Property: {finding.propertyKey}
+
+ )}
+
+
+
+
+ {finding.fix && (
+
+ )}
+
+
+
+ ))}
+
+ );
+};
+
+export default FindingList;
diff --git a/examples/experience-auditor/src/components/LocalhostWarning.tsx b/examples/experience-auditor/src/components/LocalhostWarning.tsx
new file mode 100644
index 0000000000..20be7a2c53
--- /dev/null
+++ b/examples/experience-auditor/src/components/LocalhostWarning.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Paragraph, TextLink, Note, Flex } from '@contentful/f36-components';
+
+const LocalhostWarning = () => {
+ return (
+
+
+
+ Contentful Apps need to run inside the Contentful web app to function properly. Install
+ the app into a space and render your app into one of the{' '}
+
+ available locations
+
+ .
+
+
+
+
+ Follow{' '}
+
+ our guide
+ {' '}
+ to get started or{' '}
+ open Contentful{' '}
+ to manage your app.
+
+
+
+ );
+};
+
+export default LocalhostWarning;
diff --git a/examples/experience-auditor/src/components/ScoreSummary.tsx b/examples/experience-auditor/src/components/ScoreSummary.tsx
new file mode 100644
index 0000000000..e9251f6fe4
--- /dev/null
+++ b/examples/experience-auditor/src/components/ScoreSummary.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { Badge, Flex, Heading, Text } from '@contentful/f36-components';
+import tokens from '@contentful/f36-tokens';
+import { css } from 'emotion';
+import type { AuditReport } from '../audit/types';
+
+interface ScoreSummaryProps {
+ report: AuditReport;
+}
+
+function scoreColor(score: number): string {
+ if (score >= 90) return tokens.green600;
+ if (score >= 70) return tokens.yellow600;
+ return tokens.red600;
+}
+
+const ScoreSummary = ({ report }: ScoreSummaryProps) => {
+ const { score, counts, nodeCount } = report;
+
+ return (
+
+
+
+ Health score
+
+
+ {score}
+
+
+ across {nodeCount} {nodeCount === 1 ? 'component' : 'components'}
+
+
+
+
+ {counts.error} errors
+ {counts.warning} warnings
+ {counts.info} info
+
+
+ );
+};
+
+export default ScoreSummary;
diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx
new file mode 100644
index 0000000000..d106627190
--- /dev/null
+++ b/examples/experience-auditor/src/index.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { GlobalStyles } from '@contentful/f36-components';
+import { SDKProvider } from '@contentful/react-apps-toolkit';
+
+import LocalhostWarning from './components/LocalhostWarning';
+import App from './App';
+
+const container = document.getElementById('root');
+const root = createRoot(container!);
+
+if (process.env.NODE_ENV === 'development' && window.self === window.top) {
+ // You can remove this if block before deploying your app
+ root.render();
+} else {
+ root.render(
+
+
+
+
+ );
+}
diff --git a/examples/experience-auditor/src/locations/ConfigScreen.tsx b/examples/experience-auditor/src/locations/ConfigScreen.tsx
new file mode 100644
index 0000000000..3254792092
--- /dev/null
+++ b/examples/experience-auditor/src/locations/ConfigScreen.tsx
@@ -0,0 +1,55 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { ConfigAppSDK } from '@contentful/app-sdk';
+import { Flex, Form, Heading, Note, Paragraph } from '@contentful/f36-components';
+import { useSDK } from '@contentful/react-apps-toolkit';
+
+export interface AppInstallationParameters {}
+
+const ConfigScreen = () => {
+ const [parameters, setParameters] = useState({});
+ const sdk = useSDK();
+
+ const onConfigure = useCallback(async () => {
+ // The experience-toolbar location is not part of the EditorInterface, so
+ // there is no `targetState` to assign — visibility is determined solely by
+ // whether the location is registered on the app definition (see README).
+ const currentState = await sdk.app.getCurrentState();
+ return {
+ parameters,
+ targetState: currentState,
+ };
+ }, [parameters, sdk]);
+
+ useEffect(() => {
+ sdk.app.onConfigure(() => onConfigure());
+ }, [sdk, onConfigure]);
+
+ useEffect(() => {
+ (async () => {
+ const currentParameters: AppInstallationParameters | null = await sdk.app.getParameters();
+ if (currentParameters) {
+ setParameters(currentParameters);
+ }
+ sdk.app.setReady();
+ })();
+ }, [sdk]);
+
+ return (
+
+
+
+ );
+};
+
+export default ConfigScreen;
diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx
new file mode 100644
index 0000000000..41aba1695f
--- /dev/null
+++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { render, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import ExperienceToolbar from './ExperienceToolbar';
+import { mockSdk } from '../../test/mocks';
+
+vi.mock('@contentful/react-apps-toolkit', () => ({
+ useSDK: () => mockSdk,
+}));
+
+describe('ExperienceToolbar (Experience Auditor)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSdk.exo.getUiMode.mockReturnValue('visual');
+ mockSdk.access.can.mockResolvedValue(true);
+ });
+
+ it('runs an audit on mount and renders findings with a score', async () => {
+ const { getByTestId, getAllByTestId } = render();
+
+ await waitFor(() => expect(getByTestId('health-score')).toBeInTheDocument());
+ // The default fixture has one error (missing alt) + one warning (empty heading).
+ expect(getAllByTestId('finding').length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('blocks publish while errors remain', async () => {
+ const { getByTestId } = render();
+
+ await waitFor(() => expect(getByTestId('publish-blocked')).toBeInTheDocument());
+ expect(mockSdk.exo.experience.publish).not.toHaveBeenCalled();
+ });
+
+ it('locates a finding via selection + highlight', async () => {
+ const user = userEvent.setup();
+ const { getAllByTestId } = render();
+
+ await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0));
+
+ const firstFinding = getAllByTestId('finding')[0];
+ await user.click(within(firstFinding).getByText('Locate'));
+
+ expect(mockSdk.exo.experience.selection.set).toHaveBeenCalledOnce();
+ expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith(
+ expect.any(String),
+ { flash: true, scrollIntoView: true }
+ );
+ });
+
+ it('disables locate in form mode', async () => {
+ mockSdk.exo.getUiMode.mockReturnValue('form');
+ const { getAllByTestId } = render();
+
+ await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0));
+
+ const locateButton = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button');
+ expect(locateButton).toBeDisabled();
+ });
+});
diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx
new file mode 100644
index 0000000000..619be1998a
--- /dev/null
+++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx
@@ -0,0 +1,217 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import type {
+ ExoContext,
+ ExperienceEditorToolbarAppSDK,
+ UiMode,
+} from '@contentful/app-sdk';
+import {
+ Badge,
+ Box,
+ Button,
+ Flex,
+ Heading,
+ Note,
+ Spinner,
+ Stack,
+ Text,
+} from '@contentful/f36-components';
+import { useSDK } from '@contentful/react-apps-toolkit';
+
+import { collectNodes } from '../audit/collect';
+import { hasBlockingErrors, runAudit } from '../audit/engine';
+import type { AuditFinding, AuditReport } from '../audit/types';
+import ScoreSummary from '../components/ScoreSummary';
+import FindingList from '../components/FindingList';
+
+/**
+ * Experience Auditor — a selection-aware ExO toolbar app.
+ *
+ * On mount (and whenever the experience changes) it walks the experience tree
+ * with `sdk.exo.experience`, runs a set of pure audit rules, and renders a
+ * scored list of findings. Each finding can be located on the canvas
+ * (`selection.set` + `selection.highlight`) and, where a safe deterministic fix
+ * exists, repaired in place (`getNode().setContentProperty`). Publishing is
+ * gated on there being no outstanding errors.
+ */
+const ExperienceToolbar = () => {
+ const sdk = useSDK();
+
+ const [context, setContext] = useState(() => sdk.exo.context);
+ const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode());
+ const [report, setReport] = useState(null);
+ const [auditing, setAuditing] = useState(true);
+ const [canFix, setCanFix] = useState(false);
+ const [busyFindingId, setBusyFindingId] = useState(null);
+ const [publishing, setPublishing] = useState(false);
+
+ // Guard against state updates after unmount / stale async audits.
+ const runIdRef = useRef(0);
+
+ const audit = useCallback(async () => {
+ const runId = ++runIdRef.current;
+ setAuditing(true);
+ try {
+ const nodes = await collectNodes(sdk.exo.experience);
+ const next = runAudit(nodes);
+ if (runId === runIdRef.current) {
+ setReport(next);
+ }
+ } finally {
+ if (runId === runIdRef.current) {
+ setAuditing(false);
+ }
+ }
+ }, [sdk]);
+
+ // Keep context and ui mode in sync.
+ useEffect(() => sdk.exo.onContextChanged(setContext), [sdk]);
+ useEffect(() => sdk.exo.onUiModeChanged(setUiMode), [sdk]);
+
+ // Resolve write permission once for UX gating (the host still enforces).
+ useEffect(() => {
+ let active = true;
+ sdk.access
+ .can('update', 'Entry')
+ .then((allowed) => {
+ if (active) setCanFix(allowed);
+ })
+ .catch(() => {
+ if (active) setCanFix(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [sdk]);
+
+ // Initial audit + re-audit whenever the experience changes.
+ useEffect(() => {
+ void audit();
+ return sdk.exo.experience.onChange(() => {
+ void audit();
+ });
+ }, [sdk, audit]);
+
+ const handleLocate = useCallback(
+ (finding: AuditFinding) => {
+ sdk.exo.experience.selection.set(finding.nodeId);
+ // Highlight is a no-op in form mode; the button is disabled there anyway.
+ sdk.exo.experience.selection.highlight(finding.nodeId, {
+ flash: true,
+ scrollIntoView: true,
+ });
+ },
+ [sdk]
+ );
+
+ const handleFix = useCallback(
+ async (finding: AuditFinding) => {
+ if (!finding.fix) return;
+
+ setBusyFindingId(finding.id);
+ try {
+ const node = sdk.exo.experience.getNode(finding.nodeId);
+ if (!node) {
+ sdk.notifier.error('That component no longer exists.');
+ return;
+ }
+ await node.setContentProperty(finding.fix.propertyKey, finding.fix.value);
+ sdk.notifier.success('Fix applied.');
+ await audit();
+ } catch {
+ sdk.notifier.error('Could not apply the fix. Please try again.');
+ } finally {
+ setBusyFindingId(null);
+ }
+ },
+ [sdk, audit]
+ );
+
+ const handlePublish = useCallback(async () => {
+ if (!report || hasBlockingErrors(report)) return;
+
+ setPublishing(true);
+ try {
+ const allowed = await sdk.access.can('publish', 'Entry');
+ if (!allowed) {
+ sdk.notifier.error('You do not have permission to publish this experience.');
+ return;
+ }
+ await sdk.exo.experience.publish();
+ sdk.notifier.success('Experience published.');
+ } catch {
+ sdk.notifier.error('Publish failed. Please try again.');
+ } finally {
+ setPublishing(false);
+ }
+ }, [sdk, report]);
+
+ const blocked = report ? hasBlockingErrors(report) : false;
+ const canLocate = uiMode === 'visual';
+
+ return (
+
+
+
+
+ Experience Auditor
+
+ {context.type}
+
+
+
+
+
+ {uiMode === 'form' && (
+
+ You are in form mode. Findings still update live, but locating a
+ component on the canvas requires visual mode.
+
+ )}
+
+ {report && }
+
+ {auditing && !report && (
+
+
+
+ )}
+
+ {report && (
+
+ )}
+
+
+ {blocked && (
+
+ Resolve all errors before publishing.
+
+ )}
+
+
+
+
+ );
+};
+
+export default ExperienceToolbar;
diff --git a/examples/experience-auditor/src/setupTests.ts b/examples/experience-auditor/src/setupTests.ts
new file mode 100644
index 0000000000..eb82e0f2f0
--- /dev/null
+++ b/examples/experience-auditor/src/setupTests.ts
@@ -0,0 +1,10 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
+import { configure } from '@testing-library/react';
+
+configure({
+ testIdAttribute: 'data-test-id',
+});
diff --git a/examples/experience-auditor/test/mocks/index.ts b/examples/experience-auditor/test/mocks/index.ts
new file mode 100644
index 0000000000..9ddacc64b4
--- /dev/null
+++ b/examples/experience-auditor/test/mocks/index.ts
@@ -0,0 +1 @@
+export { mockSdk, defaultNodes, noopUnsubscribe, makeMockNode } from './mockSdk';
diff --git a/examples/experience-auditor/test/mocks/mockSdk.ts b/examples/experience-auditor/test/mocks/mockSdk.ts
new file mode 100644
index 0000000000..5149fe8ba5
--- /dev/null
+++ b/examples/experience-auditor/test/mocks/mockSdk.ts
@@ -0,0 +1,75 @@
+import { vi } from 'vitest';
+import type { ComponentPropertyDescriptor } from '@contentful/app-sdk';
+
+const noopUnsubscribe = vi.fn();
+
+/** Builds a mock ExoNodeAPI whose getProperties resolves the given descriptors. */
+export function makeMockNode(
+ id: string,
+ nodeType: 'Component' | 'Fragment' | 'InlineFragment' | 'Slot',
+ properties: ComponentPropertyDescriptor[]
+) {
+ return {
+ id,
+ nodeType,
+ get: vi.fn().mockReturnValue({ id, nodeType }),
+ onChange: vi.fn().mockReturnValue(noopUnsubscribe),
+ getProperties: vi.fn().mockResolvedValue(properties),
+ getContentProperty: vi.fn().mockResolvedValue(undefined),
+ setContentProperty: vi.fn().mockResolvedValue(undefined),
+ onContentPropertyChanged: vi.fn().mockReturnValue(noopUnsubscribe),
+ };
+}
+
+const defaultNodes = [
+ makeMockNode('hero', 'Component', [
+ { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } },
+ { key: 'altText', area: 'content', value: '' }, // -> a11y error
+ { key: 'heading', area: 'content', value: 'Welcome' },
+ ]),
+ makeMockNode('cta', 'Component', [
+ { key: 'heading', area: 'content', value: '' }, // -> warning
+ ]),
+];
+
+const mockSdk: any = {
+ location: { is: vi.fn().mockReturnValue(true) },
+ ids: { app: 'test-app' },
+ app: {
+ onConfigure: vi.fn(),
+ getParameters: vi.fn().mockResolvedValue({}),
+ setReady: vi.fn(),
+ getCurrentState: vi.fn().mockResolvedValue(null),
+ },
+ access: {
+ can: vi.fn().mockResolvedValue(true),
+ },
+ notifier: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ },
+ exo: {
+ context: { type: 'experience', entityId: 'experience-123' },
+ onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe),
+ getUiMode: vi.fn().mockReturnValue('visual'),
+ onUiModeChanged: vi.fn().mockReturnValue(noopUnsubscribe),
+ experience: {
+ get: vi.fn(),
+ onChange: vi.fn().mockReturnValue(noopUnsubscribe),
+ save: vi.fn().mockResolvedValue(undefined),
+ publish: vi.fn().mockResolvedValue(undefined),
+ getNode: vi.fn((id: string) => defaultNodes.find((n) => n.id === id) ?? null),
+ getRootNodes: vi.fn().mockReturnValue(defaultNodes),
+ selection: {
+ get: vi.fn().mockReturnValue({ nodeId: null }),
+ onChange: vi.fn().mockReturnValue(noopUnsubscribe),
+ set: vi.fn(),
+ highlight: vi.fn(),
+ },
+ },
+ },
+};
+
+export { mockSdk, defaultNodes, noopUnsubscribe };
+// `makeMockNode` is exported above at its declaration.
diff --git a/examples/experience-auditor/tsconfig.json b/examples/experience-auditor/tsconfig.json
new file mode 100644
index 0000000000..697fd88e96
--- /dev/null
+++ b/examples/experience-auditor/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "exclude": ["actions", "functions"]
+}
diff --git a/examples/experience-auditor/vite.config.mts b/examples/experience-auditor/vite.config.mts
new file mode 100644
index 0000000000..438203f62d
--- /dev/null
+++ b/examples/experience-auditor/vite.config.mts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true, // Enables Jest-like global test functions (test, expect)
+ environment: 'jsdom', // Simulates a browser for component tests
+ setupFiles: './src/setupTests.ts', // Equivalent to Jest's setup file
+ },
+ base: '',
+ build: {
+ outDir: 'build',
+ },
+ server: {
+ host: 'localhost',
+ port: 3000,
+ },
+});