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..81945631e8
--- /dev/null
+++ b/examples/experience-auditor/README.md
@@ -0,0 +1,191 @@
+# 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()` →
+ `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, and only where
+ the host backs the selection surface — see _Capability-aware behavior_).
+- **One-click fixes** — findings can carry a fix that writes back via
+ `getNode().setContentProperty()`, permission-checked with `sdk.access.can()`
+ and confirmed through `sdk.notifier`. Fixes come in two kinds (see below).
+- **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 via the host's binding resolution |
+| `a11y/heading-order` | warning | Heading levels should not skip (e.g. H2 → H4) |
+
+Most rules are **per-node**: they look at a single component's properties in
+isolation. `a11y/heading-order` is **cross-node** — it reads the heading levels
+across the experience in order, so it lives alongside the per-node rules but is
+applied by the engine over the full node list rather than node-by-node.
+
+The per-node 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 per-node rule is a matter of dropping
+another `AuditRule` into `AUDIT_RULES`.
+
+### One-click fixes
+
+A finding can offer a fix, and there are two kinds — the distinction matters
+because one is safe to apply blindly and the other is not:
+
+- **Deterministic** — exactly one correct result, applied immediately on click.
+ Examples: trimming surrounding whitespace from alt text, or setting a skipped
+ heading to the level that keeps the outline sequential. There is nothing to
+ review, so the app just writes the value.
+- **Suggested** — a proposed value the author reviews and edits _before_ it is
+ written. Example: deriving an SEO meta value from the component's heading. The
+ app pre-fills the suggestion with its provenance, and the author can accept it
+ as-is or change it. **A suggested value is never written silently** — the
+ write only happens after the author confirms.
+
+The fix shapes are modelled as a discriminated union in
+[`src/audit/types.ts`](src/audit/types.ts) (`AutoFix`), so the toolbar can route
+each kind to the right UI and the rules stay declarative about what they offer.
+
+### Capability-aware behavior
+
+Not every `sdk.exo` surface is backed by every host, and surfaces can roll out
+incrementally. Rather than call a method and catch the failure, the app probes
+up front which surfaces are available
+([`src/audit/capabilities.ts`](src/audit/capabilities.ts)) and adapts its UI to
+match. Concretely: where the host does not back selection, **Locate** is
+rendered **disabled** with an explanation of why, instead of calling an
+unsupported API and erroring.
+
+This is a pattern worth copying for any app that depends on optional or
+still-rolling-out host capabilities: detect once, degrade gracefully, and tell
+the user why an affordance is unavailable rather than letting it fail.
+
+## Architecture
+
+```
+src/
+ audit/
+ types.ts SDK-independent domain types (CollectedNode, AuditFinding, AutoFix, …)
+ rules.ts Pure audit rules (per-node + the cross-node heading-order rule)
+ engine.ts Runs rules, aggregates findings, computes the score
+ fixes.ts Pure derivation of suggested fix values (e.g. meta from heading)
+ capabilities.ts Probes which optional sdk.exo surfaces the host backs
+ collect.ts The only SDK-coupled piece: walks sdk.exo.experience → CollectedNode[]
+ components/
+ ScoreSummary.tsx
+ FindingList.tsx Groups findings by severity; renders locate + fix affordances
+ SuggestedFix.tsx Review-and-edit step for suggested fixes
+ EmptyState.tsx
+ demo/
+ DemoProvider.tsx Dev-only: renders the toolbar against the seeded mock (?demo)
+ mockExo.ts Seeded in-memory sdk.exo for the demo
+ 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 — rules, scoring, suggested-fix derivation,
+capability detection — 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
+```
+
+### Try it locally (demo mode)
+
+You can drive the full audit → suggested-fix → re-score loop without a live host
+at all:
+
+```bash
+npm start
+# then open:
+http://localhost:3000/?demo
+```
+
+The `?demo` flag renders the toolbar against a seeded, in-memory experience so
+you can click an audit, accept or edit a suggested fix, and watch the score
+update. It is a **dev-only convenience with no live canvas** — because there is
+no real selection surface behind it, **Locate is disabled in the demo** (an
+illustration of the capability-aware behavior described above). The demo
+scaffolding is dynamically imported and stripped from production builds; the
+real runtime is inside the Contentful host.
+
+## 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. 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** — 40 tests cover the audit rules, scoring,
+the collector and its binding resolution, capability detection, the suggested-
+fix derivation, and the toolbar's locate / fix / publish-gate behavior. It is
+not yet verified end-to-end inside a live ExO editor; that live verification is
+tracked separately as the host renderer rolls out. 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..b907f0dc5f
--- /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.2",
+ "@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..c02cf5bd71
--- /dev/null
+++ b/examples/experience-auditor/src/audit/audit.spec.ts
@@ -0,0 +1,294 @@
+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({
+ kind: 'deterministic',
+ 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('does not flag string fields whose key merely looks image-ish', () => {
+ // `iconName`/`logoText`/`assetId` are string labels, not images — they must
+ // not trigger a (publish-blocking) missing-alt error.
+ const report = runAudit([
+ node('badge', [
+ { key: 'iconName', area: 'content', value: 'star' },
+ { key: 'logoText', area: 'content', value: 'ACME' },
+ { key: 'assetId', area: 'content', value: 'abc123' },
+ ]),
+ ]);
+ expect(report.findings.filter((f) => f.ruleId === 'a11y/image-alt-text')).toHaveLength(0);
+ expect(report.counts.error).toBe(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('does not double-fire on a key that matches both heading and meta hints', () => {
+ // `metaTitle` matches the meta hint and the bare "title" — it should be
+ // owned by the SEO rule only, producing exactly one finding.
+ const report = runAudit([node('page', [{ key: 'metaTitle', area: 'content', value: '' }])]);
+ const forKey = report.findings.filter((f) => f.propertyKey === 'metaTitle');
+ expect(forKey).toHaveLength(1);
+ expect(forKey[0].ruleId).toBe('seo/missing-meta');
+ });
+
+ it('flags a broken entry binding as an error', () => {
+ const report = runAudit([
+ node('card', [
+ {
+ key: 'title',
+ area: 'content',
+ value: null,
+ binding: { type: 'entry', entryId: '', fieldId: 'title' },
+ },
+ ]),
+ ]);
+ 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: { type: 'entry', entryId: 'entry-1', fieldId: 'title' },
+ },
+ ]),
+ ]);
+ expect(report.findings.find((f) => f.ruleId === 'content/broken-binding')).toBeUndefined();
+ });
+
+ it('flags a heading level that skips a level', () => {
+ const report = runAudit([
+ node('a', [{ key: 'headingLevel', area: 'content', value: 2 }]),
+ node('b', [{ key: 'headingLevel', area: 'content', value: 4 }]),
+ ]);
+ const finding = report.findings.find((f) => f.ruleId === 'a11y/heading-order');
+ expect(finding?.severity).toBe('warning');
+ expect(finding?.fix).toEqual({
+ kind: 'deterministic',
+ label: 'Set to H3',
+ propertyKey: 'headingLevel',
+ value: 3,
+ });
+ });
+
+ it('does not flag sequential heading levels', () => {
+ const report = runAudit([
+ node('a', [{ key: 'headingLevel', area: 'content', value: 2 }]),
+ node('b', [{ key: 'headingLevel', area: 'content', value: 3 }]),
+ ]);
+ expect(report.findings.filter((f) => f.ruleId === 'a11y/heading-order')).toHaveLength(0);
+ });
+
+ it('offers a suggested meta value derived from the heading', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'heading', area: 'content', value: 'Spring Sale' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ]),
+ ]);
+ const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta');
+ expect(finding?.fix).toEqual({
+ kind: 'suggested',
+ label: 'Use heading as meta',
+ propertyKey: 'metaTitle',
+ suggestedValue: 'Spring Sale',
+ source: 'the heading on this component',
+ });
+ });
+
+ it('suggests meta from the real heading even when a numeric headingLevel is present', () => {
+ const report = runAudit([
+ node('hero', [
+ { key: 'headingLevel', area: 'content', value: 2 },
+ { key: 'heading', area: 'content', value: 'Spring Sale' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ]),
+ ]);
+ const finding = report.findings.find((f) => f.ruleId === 'seo/missing-meta');
+ expect(finding?.fix).toEqual({
+ kind: 'suggested',
+ label: 'Use heading as meta',
+ propertyKey: 'metaTitle',
+ suggestedValue: 'Spring Sale',
+ source: 'the heading on this component',
+ });
+ });
+
+ it('flags an entry binding that fails to resolve via resolvedBindings', () => {
+ const n: CollectedNode = {
+ id: 'card',
+ nodeType: 'Component',
+ properties: [
+ {
+ key: 'featured',
+ area: 'content',
+ value: null,
+ binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' },
+ },
+ ],
+ resolvedBindings: { featured: { resolved: false } },
+ };
+ const finding = runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding');
+ expect(finding?.severity).toBe('error');
+ });
+
+ it('does not flag a binding that resolves', () => {
+ const n: CollectedNode = {
+ id: 'card',
+ nodeType: 'Component',
+ properties: [
+ {
+ key: 'featured',
+ area: 'content',
+ value: 'x',
+ binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' },
+ },
+ ],
+ resolvedBindings: { featured: { resolved: true } },
+ };
+ expect(
+ runAudit([n]).findings.find((f) => f.ruleId === 'content/broken-binding')
+ ).toBeUndefined();
+ });
+
+ it('exposes a stable rule set', () => {
+ // Per-node rules only; the cross-node a11y/heading-order rule is invoked by
+ // the engine over the full node list, not registered in AUDIT_RULES.
+ 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/capabilities.spec.ts b/examples/experience-auditor/src/audit/capabilities.spec.ts
new file mode 100644
index 0000000000..32a673dc35
--- /dev/null
+++ b/examples/experience-auditor/src/audit/capabilities.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { detectCapabilities } from './capabilities';
+
+describe('detectCapabilities', () => {
+ it('reports selection supported when the experience exposes a selection API', () => {
+ const exo: any = {
+ experience: { selection: { set: () => {}, highlight: () => {} } },
+ };
+ expect(detectCapabilities(exo)).toEqual({ selection: true });
+ });
+
+ it('reports selection unsupported when selection is absent', () => {
+ const exo: any = { experience: {} };
+ expect(detectCapabilities(exo)).toEqual({ selection: false });
+ });
+
+ it('reports selection unsupported when set/highlight are not functions', () => {
+ const exo: any = { experience: { selection: { set: null, highlight: undefined } } };
+ expect(detectCapabilities(exo)).toEqual({ selection: false });
+ });
+});
diff --git a/examples/experience-auditor/src/audit/capabilities.ts b/examples/experience-auditor/src/audit/capabilities.ts
new file mode 100644
index 0000000000..60f69bb947
--- /dev/null
+++ b/examples/experience-auditor/src/audit/capabilities.ts
@@ -0,0 +1,18 @@
+import type { ExoSDK } from '@contentful/app-sdk';
+import type { Capabilities } from './types';
+
+/**
+ * Probes which optional host surfaces the live `sdk.exo` actually backs.
+ *
+ * The toolbar host bridge intentionally degrades some surfaces (notably
+ * selection) to "not supported" until later host work lands. Rather than call
+ * an unsupported method and catch, the app asks this probe up front and renders
+ * an informative, disabled affordance instead. This is the pattern a
+ * well-behaved app uses when a host capability is still rolling out.
+ */
+export function detectCapabilities(exo: ExoSDK): Capabilities {
+ const selection = exo.experience?.selection;
+ return {
+ selection: typeof selection?.set === 'function' && typeof selection?.highlight === 'function',
+ };
+}
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..b5f55cd65a
--- /dev/null
+++ b/examples/experience-auditor/src/audit/collect.spec.ts
@@ -0,0 +1,100 @@
+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');
+ });
+
+ it('populates resolvedBindings when the node resolves an entry binding', async () => {
+ const okNode = makeMockNode('card', 'Component', [
+ {
+ key: 'featured',
+ area: 'content',
+ value: 'x',
+ binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' },
+ },
+ ]);
+ okNode.resolveEntryBinding = vi.fn().mockResolvedValue({ entryId: 'e1' });
+ const experience: any = { getRootNodes: vi.fn().mockReturnValue([okNode]) };
+ const [collected] = await collectNodes(experience);
+ expect(collected.resolvedBindings).toEqual({
+ featured: { resolved: true },
+ });
+ });
+
+ it('marks a binding unresolved when resolveEntryBinding returns null', async () => {
+ const brokenNode = makeMockNode('card', 'Component', [
+ {
+ key: 'featured',
+ area: 'content',
+ value: null,
+ binding: { type: 'entry', entryId: 'gone', fieldId: 'featured' },
+ },
+ ]);
+ brokenNode.resolveEntryBinding = vi.fn().mockResolvedValue(null);
+ const experience: any = { getRootNodes: vi.fn().mockReturnValue([brokenNode]) };
+ const [collected] = await collectNodes(experience);
+ expect(collected.resolvedBindings).toEqual({
+ featured: { resolved: false },
+ });
+ });
+
+ it('omits resolvedBindings when no property is entry-bound', async () => {
+ const node = makeMockNode('plain', 'Component', [
+ { key: 'body', area: 'content', value: 'hi' },
+ ]);
+ const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) };
+ const [collected] = await collectNodes(experience);
+ expect(collected.resolvedBindings).toBeUndefined();
+ });
+
+ it('omits resolvedBindings when the host does not implement resolveEntryBinding', async () => {
+ const node = makeMockNode('card', 'Component', [
+ {
+ key: 'featured',
+ area: 'content',
+ value: 'x',
+ binding: { type: 'entry', entryId: 'e1', fieldId: 'featured' },
+ },
+ ]);
+ // Simulate a partial host bridge that has not shipped resolveEntryBinding yet.
+ delete (node as { resolveEntryBinding?: unknown }).resolveEntryBinding;
+ const experience: any = { getRootNodes: vi.fn().mockReturnValue([node]) };
+ const [collected] = await collectNodes(experience);
+ expect(collected.resolvedBindings).toBeUndefined();
+ });
+});
diff --git a/examples/experience-auditor/src/audit/collect.ts b/examples/experience-auditor/src/audit/collect.ts
new file mode 100644
index 0000000000..ff602c1107
--- /dev/null
+++ b/examples/experience-auditor/src/audit/collect.ts
@@ -0,0 +1,59 @@
+import type { ComponentPropertyDescriptor, ExoNodeAPI, ExperienceAPI } from '@contentful/app-sdk';
+import type { CollectedNode, ResolvedBinding } 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();
+ const resolvedBindings = await resolveBindings(node, properties);
+ return {
+ id: node.id,
+ nodeType: node.nodeType,
+ properties,
+ ...(resolvedBindings ? { resolvedBindings } : {}),
+ } 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);
+}
+
+/**
+ * Resolves each entry-bound property via the host's `resolveEntryBinding`,
+ * capturing whether the reference actually resolves. Returns `undefined` when
+ * the host does not back resolution (older/partial bridge) or the node has no
+ * entry-bound properties, so the binding rule falls back to its structural check.
+ * SDK coupling lives here, not in the rules.
+ */
+async function resolveBindings(
+ node: ExoNodeAPI,
+ properties: ComponentPropertyDescriptor[]
+): Promise | undefined> {
+ if (typeof node.resolveEntryBinding !== 'function') return undefined;
+
+ const entries: Array<[string, ResolvedBinding]> = [];
+ for (const property of properties) {
+ if (property.binding?.type !== 'entry') continue;
+ const target = await node.resolveEntryBinding(property.key);
+ entries.push([property.key, { resolved: target !== null }]);
+ }
+
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+}
diff --git a/examples/experience-auditor/src/audit/engine.ts b/examples/experience-auditor/src/audit/engine.ts
new file mode 100644
index 0000000000..db596938a4
--- /dev/null
+++ b/examples/experience-auditor/src/audit/engine.ts
@@ -0,0 +1,60 @@
+import type { AuditFinding, AuditReport, AuditRule, CollectedNode, Severity } from './types';
+import { AUDIT_RULES, evaluateHeadingOrder } 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));
+ }
+ }
+ findings.push(...evaluateHeadingOrder(nodes));
+
+ 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/fixes.spec.ts b/examples/experience-auditor/src/audit/fixes.spec.ts
new file mode 100644
index 0000000000..4eabb280f8
--- /dev/null
+++ b/examples/experience-auditor/src/audit/fixes.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import type { ComponentPropertyDescriptor } from '@contentful/app-sdk';
+import { suggestMetaFromHeading } from './fixes';
+import type { CollectedNode } from './types';
+
+function node(properties: ComponentPropertyDescriptor[]): CollectedNode {
+ return { id: 'n', nodeType: 'Component', properties };
+}
+
+describe('suggestMetaFromHeading', () => {
+ it('proposes the heading text as the meta value', () => {
+ const result = suggestMetaFromHeading(
+ node([
+ { key: 'heading', area: 'content', value: ' Our Spring Sale ' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ])
+ );
+ expect(result).toBe('Our Spring Sale');
+ });
+
+ it('returns null when there is no non-empty heading', () => {
+ const result = suggestMetaFromHeading(
+ node([
+ { key: 'heading', area: 'content', value: ' ' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ])
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns null when the heading is not a string', () => {
+ const result = suggestMetaFromHeading(
+ node([
+ { key: 'heading', area: 'content', value: { sys: { id: 'x' } } },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ])
+ );
+ expect(result).toBeNull();
+ });
+});
diff --git a/examples/experience-auditor/src/audit/fixes.ts b/examples/experience-auditor/src/audit/fixes.ts
new file mode 100644
index 0000000000..16a78f87f2
--- /dev/null
+++ b/examples/experience-auditor/src/audit/fixes.ts
@@ -0,0 +1,17 @@
+import type { CollectedNode } from './types';
+import { stripNonAlpha, HEADING_KEY_HINT, HEADING_LEVEL_HINT } from './keys';
+
+/**
+ * Derives a suggested SEO meta value from the node's heading, when one exists.
+ * Pure: reads only the CollectedNode. Returns the trimmed heading text, or
+ * `null` when there is no usable heading (the caller then offers no suggestion).
+ */
+export function suggestMetaFromHeading(node: CollectedNode): string | null {
+ const heading = node.properties.find(
+ (p) =>
+ HEADING_KEY_HINT.test(stripNonAlpha(p.key)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key))
+ );
+ if (!heading || typeof heading.value !== 'string') return null;
+ const trimmed = heading.value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
diff --git a/examples/experience-auditor/src/audit/keys.ts b/examples/experience-auditor/src/audit/keys.ts
new file mode 100644
index 0000000000..2e5be6f87f
--- /dev/null
+++ b/examples/experience-auditor/src/audit/keys.ts
@@ -0,0 +1,15 @@
+/** Case-insensitive, punctuation-stripped key matching shared by rules and fixes. */
+export function stripNonAlpha(key: string): string {
+ return key.replace(/[^a-z0-9]/gi, '');
+}
+
+export const IMAGE_KEY_HINT = /(image|photo|media|thumbnail)/i;
+export const ALT_KEY_HINT = /(alt|alttext|alternativetext|a11ylabel|arialabel)/i;
+export const META_KEY_HINT =
+ /(metadescription|seodescription|metatitle|seotitle|opengraph|ogtitle|ogdescription)/i;
+// Heading must look like a heading but NOT like SEO metadata — otherwise
+// `metaTitle`/`seoTitle` would match both rules and double-count one field.
+export const HEADING_KEY_HINT = /(heading|headline|^title$|pagetitle)/i;
+// Matches a dedicated heading-level key (e.g. `headingLevel`, `hLevel`, `level`)
+// without catching unrelated `*Level` keys like `nestingLevel` or `accessLevel`.
+export const HEADING_LEVEL_HINT = /(headinglevel|hlevel|^level$)/i;
diff --git a/examples/experience-auditor/src/audit/rules.ts b/examples/experience-auditor/src/audit/rules.ts
new file mode 100644
index 0000000000..e226cdc599
--- /dev/null
+++ b/examples/experience-auditor/src/audit/rules.ts
@@ -0,0 +1,266 @@
+import type { ComponentPropertyDescriptor } from '@contentful/app-sdk';
+import type { AuditFinding, AuditRule, CollectedNode, Severity } from './types';
+import {
+ stripNonAlpha,
+ IMAGE_KEY_HINT,
+ ALT_KEY_HINT,
+ META_KEY_HINT,
+ HEADING_KEY_HINT,
+ HEADING_LEVEL_HINT,
+} from './keys';
+import { suggestMetaFromHeading } from './fixes';
+
+function findProperty(
+ node: CollectedNode,
+ matcher: RegExp,
+ predicate?: (p: ComponentPropertyDescriptor) => boolean
+): ComponentPropertyDescriptor | undefined {
+ return node.properties.find(
+ (p) => matcher.test(stripNonAlpha(p.key)) && (!predicate || predicate(p))
+ );
+}
+
+/**
+ * Whether a property value looks like an actual image — an asset Link object
+ * (`{ sys: { linkType: 'Asset', ... } }`) or an array of them. Matching on the
+ * key alone is too loose: `iconName`, `logoText`, `assetId` are string labels,
+ * not images, and flagging them for missing alt text produces false errors.
+ */
+function looksLikeImageValue(value: unknown): boolean {
+ const isAssetLink = (v: unknown): boolean =>
+ typeof v === 'object' && v !== null && 'sys' in (v as Record);
+ if (Array.isArray(value)) return value.some(isAssetLink);
+ return isAssetLink(value);
+}
+
+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) {
+ // Require both an image-like key AND an asset-shaped value, so string
+ // fields like `iconName`/`logoText` don't trigger false alt-text errors.
+ const image = findProperty(node, IMAGE_KEY_HINT, (p) => looksLikeImageValue(p.value));
+ 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: {
+ kind: 'deterministic',
+ 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) {
+ // Exclude meta-ish keys so `metaTitle`/`seoTitle` are owned solely by the
+ // SEO rule and don't double-fire here.
+ const heading = findProperty(
+ node,
+ HEADING_KEY_HINT,
+ (p) =>
+ !META_KEY_HINT.test(stripNonAlpha(p.key)) && !HEADING_LEVEL_HINT.test(stripNonAlpha(p.key))
+ );
+ 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)) {
+ const suggestion = suggestMetaFromHeading(node);
+ 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.`,
+ fix: suggestion
+ ? {
+ kind: 'suggested',
+ label: 'Use heading as meta',
+ propertyKey: meta.key,
+ suggestedValue: suggestion,
+ source: 'the heading on this component',
+ }
+ : undefined,
+ }),
+ ];
+ }
+ return [];
+ },
+};
+
+/**
+ * Content properties bound to an entry must actually resolve. When the collector
+ * recorded a real resolution (`resolvedBindings`), prefer it: a binding is broken
+ * iff it is an entry binding that did not resolve. Where no resolution is present
+ * (host without `resolveEntryBinding`), fall back to the structural check — an
+ * entry source with no recorded `entryId` 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.type !== 'entry') continue;
+
+ const resolution = node.resolvedBindings?.[property.key];
+ const broken = resolution ? !resolution.resolved : !binding.entryId;
+
+ if (broken) {
+ 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 does not resolve.`,
+ })
+ );
+ }
+ }
+ return findings;
+ },
+};
+
+function headingLevelOf(node: CollectedNode): { level: number; key: string } | undefined {
+ const prop = node.properties.find((p) => HEADING_LEVEL_HINT.test(stripNonAlpha(p.key)));
+ if (!prop || typeof prop.value !== 'number') return undefined;
+ return { level: prop.value, key: prop.key };
+}
+
+/**
+ * Heading levels should not skip (e.g. H2 -> H4 is an a11y/SEO problem).
+ * Order-sensitive across nodes, so unlike the per-node rules this is applied by
+ * the engine over the full node list.
+ */
+export function evaluateHeadingOrder(nodes: CollectedNode[]): AuditFinding[] {
+ const findings: AuditFinding[] = [];
+ let previous: number | undefined;
+ for (const node of nodes) {
+ const heading = headingLevelOf(node);
+ if (heading === undefined) continue;
+ const { level, key } = heading;
+ if (previous !== undefined && level > previous + 1) {
+ const expected = previous + 1;
+ findings.push(
+ makeFinding({ id: 'a11y/heading-order' }, node, {
+ propertyKey: key,
+ severity: 'warning',
+ title: 'Heading level skips a level',
+ detail: `This heading is H${level} but follows an H${previous}. Use H${expected} so the outline is sequential.`,
+ fix: {
+ kind: 'deterministic',
+ label: `Set to H${expected}`,
+ propertyKey: key,
+ value: expected,
+ },
+ })
+ );
+ }
+ previous = level;
+ }
+ 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..da800c8c04
--- /dev/null
+++ b/examples/experience-auditor/src/audit/types.ts
@@ -0,0 +1,81 @@
+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[];
+ /**
+ * Per-property-key binding resolution, populated by the collector when the
+ * host backs `resolveEntryBinding`. Absent when the host does not support it
+ * (the binding rule then falls back to a structural check).
+ */
+ resolvedBindings?: Record;
+}
+
+/** The resolution result for an entry-bound property, captured at collect time. */
+export interface ResolvedBinding {
+ /** True when `resolveEntryBinding` returned a target; false when it returned null. */
+ resolved: boolean;
+}
+
+/**
+ * A one-click fix for a finding.
+ *
+ * - `deterministic`: exactly one correct result (e.g. trimming whitespace).
+ * Applied immediately on click, no confirmation.
+ * - `suggested`: a proposed value the author reviews and edits before it is
+ * written. Used where the fix is helpful but not unambiguous (e.g. deriving a
+ * meta title from the heading). We never write a suggested value silently.
+ */
+export type AutoFix =
+ | { kind: 'deterministic'; label: string; propertyKey: string; value: unknown }
+ | {
+ kind: 'suggested';
+ label: string;
+ propertyKey: string;
+ /** Pre-filled, editable proposed value. */
+ suggestedValue: string;
+ /** Human-readable provenance, e.g. "from the heading on this component". */
+ source: string;
+ };
+
+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;
+}
+
+/** Which optional host surfaces are backed by the live `sdk.exo`. */
+export interface Capabilities {
+ /** Selection/highlight (Locate-on-canvas). Not backed on the experience route yet. */
+ selection: boolean;
+}
diff --git a/examples/experience-auditor/src/components/EmptyState.tsx b/examples/experience-auditor/src/components/EmptyState.tsx
new file mode 100644
index 0000000000..5c4e5fdb2f
--- /dev/null
+++ b/examples/experience-auditor/src/components/EmptyState.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { Note } from '@contentful/f36-components';
+
+/** Celebration state shown when the experience passes every audit rule. */
+const EmptyState = () => (
+
+ 🎉 No issues found. This experience passes every audit rule.
+
+);
+
+export default EmptyState;
diff --git a/examples/experience-auditor/src/components/FindingList.spec.tsx b/examples/experience-auditor/src/components/FindingList.spec.tsx
new file mode 100644
index 0000000000..7ee430015d
--- /dev/null
+++ b/examples/experience-auditor/src/components/FindingList.spec.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import FindingList from './FindingList';
+import type { AuditFinding } from '../audit/types';
+
+function suggestedFinding(suggestedValue: string): AuditFinding {
+ return {
+ id: 'seo/missing-meta:page:metaTitle', // stable across re-audits
+ ruleId: 'seo/missing-meta',
+ nodeId: 'page',
+ nodeType: 'Component',
+ propertyKey: 'metaTitle',
+ severity: 'info',
+ title: 'SEO metadata is empty',
+ detail: 'metaTitle is empty.',
+ fix: {
+ kind: 'suggested',
+ label: 'Use heading as meta',
+ propertyKey: 'metaTitle',
+ suggestedValue,
+ source: 'the heading on this component',
+ },
+ };
+}
+
+describe('FindingList suggested-fix seeding', () => {
+ it('re-seeds the suggested value when the suggestion changes for the same finding id', () => {
+ const props = {
+ canLocate: false,
+ canFix: true,
+ onLocate: vi.fn(),
+ onApplyDeterministic: vi.fn(),
+ onApplySuggested: vi.fn(),
+ busyFindingId: null,
+ };
+ const { getByLabelText, rerender } = render(
+
+ );
+ expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale');
+
+ // Same finding.id, new derived suggestion (heading changed, meta still empty).
+ rerender();
+ expect((getByLabelText('Suggested value') as HTMLInputElement).value).toBe('Spring Sale 2026');
+ });
+});
diff --git a/examples/experience-auditor/src/components/FindingList.tsx b/examples/experience-auditor/src/components/FindingList.tsx
new file mode 100644
index 0000000000..e1adfdfab2
--- /dev/null
+++ b/examples/experience-auditor/src/components/FindingList.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { Badge, Box, Button, Flex, Stack, Text } from '@contentful/f36-components';
+import type { BadgeProps } from '@contentful/f36-components';
+import type { AuditFinding, Severity } from '../audit/types';
+import SuggestedFix from './SuggestedFix';
+import EmptyState from './EmptyState';
+
+interface FindingListProps {
+ findings: AuditFinding[];
+ canLocate: boolean;
+ canFix: boolean;
+ onLocate: (finding: AuditFinding) => void;
+ onApplyDeterministic: (finding: AuditFinding) => void;
+ onApplySuggested: (finding: AuditFinding, value: string) => void;
+ busyFindingId: string | null;
+}
+
+const SEVERITY_VARIANT: Record = {
+ error: 'negative',
+ warning: 'warning',
+ info: 'secondary',
+};
+
+const SEVERITY_ORDER: Severity[] = ['error', 'warning', 'info'];
+const SEVERITY_LABEL: Record = {
+ error: 'Errors',
+ warning: 'Warnings',
+ info: 'Info',
+};
+
+const FindingList = ({
+ findings,
+ canLocate,
+ canFix,
+ onLocate,
+ onApplyDeterministic,
+ onApplySuggested,
+ busyFindingId,
+}: FindingListProps) => {
+ if (findings.length === 0) return ;
+
+ return (
+
+ {SEVERITY_ORDER.map((severity) => {
+ const group = findings.filter((f) => f.severity === severity);
+ if (group.length === 0) return null;
+ return (
+
+
+ {SEVERITY_LABEL[severity]} · {group.length}
+
+ {group.map((finding) => (
+
+
+
+
+ {finding.severity}
+ {finding.title}
+
+
+ {finding.detail}
+
+ {finding.fix?.kind === 'suggested' && (
+ // Key on the suggested value so a *changed* suggestion (same
+ // finding.id, new derived value) remounts SuggestedFix and
+ // re-seeds its state; an unchanged suggestion keeps the same
+ // key, preserving the author's in-progress edit across a
+ // no-op re-audit. React's "reset state with a key" idiom —
+ // preferred over a useEffect-sync anti-pattern.
+ onApplySuggested(finding, value)}
+ />
+ )}
+
+
+
+ {finding.fix?.kind === 'deterministic' && (
+
+ )}
+
+
+
+ ))}
+
+ );
+ })}
+
+ );
+};
+
+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..b4b910a0a1
--- /dev/null
+++ b/examples/experience-auditor/src/components/ScoreSummary.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { Badge, Flex, 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;
+ const color = scoreColor(score);
+ const radius = 28;
+ const circumference = 2 * Math.PI * radius;
+ const offset = circumference * (1 - score / 100);
+
+ return (
+
+
+
+
+
+ {score}
+
+
+
+
+ Health 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/components/SuggestedFix.tsx b/examples/experience-auditor/src/components/SuggestedFix.tsx
new file mode 100644
index 0000000000..97fc16d4b0
--- /dev/null
+++ b/examples/experience-auditor/src/components/SuggestedFix.tsx
@@ -0,0 +1,70 @@
+import React, { useState } from 'react';
+import { Box, Button, Flex, Text, TextInput } from '@contentful/f36-components';
+import tokens from '@contentful/f36-tokens';
+
+interface SuggestedFixProps {
+ /** The proposed value, pre-filled and editable. */
+ suggestedValue: string;
+ /** Human-readable provenance shown above the field. */
+ source: string;
+ /** Whether the current user may write (mirrors the deterministic Fix gate). */
+ canApply: boolean;
+ /** True while the write is in flight. */
+ isApplying: boolean;
+ /** Apply the (possibly edited) value. */
+ onApply: (value: string) => void;
+}
+
+/**
+ * The confirm-step for a `suggested` fix. Unlike a deterministic fix (applied on
+ * click), a suggested value is shown in an editable field so the author reviews
+ * and adjusts it before it is written. This keeps the app from silently writing
+ * an opinionated value.
+ */
+const SuggestedFix = ({
+ suggestedValue,
+ source,
+ canApply,
+ isApplying,
+ onApply,
+}: SuggestedFixProps) => {
+ const [value, setValue] = useState(suggestedValue);
+
+ return (
+
+
+ 💡 Suggested from {source}
+
+
+ setValue(e.target.value)}
+ isDisabled={!canApply || isApplying}
+ aria-label="Suggested value"
+ />
+
+
+
+ Editable before write · confirms via setContentProperty + notifier
+
+
+ );
+};
+
+export default SuggestedFix;
diff --git a/examples/experience-auditor/src/demo/DemoProvider.tsx b/examples/experience-auditor/src/demo/DemoProvider.tsx
new file mode 100644
index 0000000000..332697b7c0
--- /dev/null
+++ b/examples/experience-auditor/src/demo/DemoProvider.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { SDKContext } from '@contentful/react-apps-toolkit';
+import { GlobalStyles } from '@contentful/f36-components';
+
+import { demoSdk } from './mockExo';
+import ExperienceToolbar from '../locations/ExperienceToolbar';
+
+/**
+ * DEMO ONLY — renders the toolbar against a seeded in-memory `sdk.exo` so the
+ * audit -> suggested-fix -> re-score loop is clickable via `npm start` before
+ * the host renderer is broadly available. Never used on the real app path.
+ *
+ * The toolkit's `useSDK()` reads `useContext(SDKContext).sdk`, so the context
+ * value must be `{ sdk }` — matching what the real `SDKProvider` supplies.
+ */
+const DemoProvider = () => (
+
+
+
+
+);
+
+export default DemoProvider;
diff --git a/examples/experience-auditor/src/demo/mockExo.ts b/examples/experience-auditor/src/demo/mockExo.ts
new file mode 100644
index 0000000000..2790f1ac65
--- /dev/null
+++ b/examples/experience-auditor/src/demo/mockExo.ts
@@ -0,0 +1,81 @@
+import type { ExperienceEditorToolbarAppSDK } from '@contentful/app-sdk';
+
+/**
+ * DEMO ONLY — scaffolding for the standalone `?demo` mode. Typed loosely on
+ * purpose: the mock only implements the surfaces the toolbar and collector
+ * actually touch, and a single `as unknown as ExperienceEditorToolbarAppSDK`
+ * cast at the export bridges it to the real SDK type. Not used on any real path.
+ */
+
+interface DemoProperty {
+ key: string;
+ area: 'content';
+ value: unknown;
+}
+
+function makeNode(id: string, initialProps: DemoProperty[]) {
+ let props = initialProps;
+ return {
+ id,
+ nodeType: 'Component' as const,
+ get: () => ({ id, nodeType: 'Component' as const }),
+ onChange: () => () => {},
+ getProperties: async () => props,
+ getContentProperty: async () => undefined,
+ setContentProperty: async (key: string, value: unknown) => {
+ // Mutate in place so a re-audit reflects the applied fix.
+ props = props.map((p) => (p.key === key ? { ...p, value } : p));
+ },
+ onContentPropertyChanged: () => () => {},
+ resolveEntryBinding: async () => ({ entryId: 'demo-entry' }),
+ };
+}
+
+// Three seeded nodes, each planting exactly one finding:
+// - `hero`: image present + empty altText -> error (missing alt text)
+// - `cta`: empty heading -> warning (empty heading)
+// - `page`: heading present + empty metaTitle -> info + suggested fix
+const nodes = [
+ makeNode('hero', [
+ { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } },
+ { key: 'altText', area: 'content', value: '' },
+ { key: 'heading', area: 'content', value: 'Welcome' },
+ ]),
+ makeNode('cta', [{ key: 'heading', area: 'content', value: '' }]),
+ makeNode('page', [
+ { key: 'heading', area: 'content', value: 'Our Spring Sale' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ]),
+];
+
+/**
+ * DEMO ONLY — a minimal seeded `sdk.exo`. `experience.selection` is deliberately
+ * omitted so the demo exercises the graceful "Locate not available" degradation
+ * (`detectCapabilities` reports `selection: false` and the button renders
+ * disabled).
+ */
+export const demoSdk = {
+ location: { is: () => true },
+ ids: { app: 'demo-app' },
+ access: { can: async () => true },
+ notifier: {
+ success: (m: string) => console.info('[demo notifier]', m),
+ error: (m: string) => console.warn('[demo notifier]', m),
+ warning: (m: string) => console.warn('[demo notifier]', m),
+ },
+ exo: {
+ context: { type: 'experience' as const, entityId: 'demo-experience' },
+ onContextChanged: () => () => {},
+ getUiMode: () => 'visual' as const,
+ onUiModeChanged: () => () => {},
+ experience: {
+ get: () => ({ sys: { id: 'demo-experience' } }),
+ onChange: () => () => {},
+ save: async () => {},
+ publish: async () => {},
+ getNode: (id: string) => nodes.find((n) => n.id === id) ?? null,
+ getRootNodes: () => nodes,
+ // selection intentionally omitted (see above)
+ },
+ },
+} as unknown as ExperienceEditorToolbarAppSDK;
diff --git a/examples/experience-auditor/src/index.tsx b/examples/experience-auditor/src/index.tsx
new file mode 100644
index 0000000000..4727051572
--- /dev/null
+++ b/examples/experience-auditor/src/index.tsx
@@ -0,0 +1,35 @@
+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!);
+
+// `process.env.NODE_ENV` is evaluated first so the URL parse is fully
+// eliminated from production builds (the whole `&&` folds away).
+const demoMode =
+ process.env.NODE_ENV === 'development' && new URLSearchParams(window.location.search).has('demo');
+
+if (demoMode) {
+ // Standalone demo: `npm start` + `?demo` renders the toolbar against a seeded
+ // in-memory sdk.exo. Dynamically imported so the demo scaffolding stays out of
+ // the production bundle.
+ void import('./demo/DemoProvider').then(({ default: DemoProvider }) => {
+ root.render();
+ });
+} else 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..601af0cca1
--- /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..fa3f4b75cb
--- /dev/null
+++ b/examples/experience-auditor/src/locations/ExperienceToolbar.spec.tsx
@@ -0,0 +1,146 @@
+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, defaultNodes } 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);
+ mockSdk.exo.experience.selection = {
+ get: vi.fn().mockReturnValue({ nodeId: null }),
+ onChange: vi.fn().mockReturnValue(vi.fn()),
+ set: vi.fn(),
+ highlight: vi.fn(),
+ };
+ mockSdk.exo.experience.getRootNodes.mockReturnValue(defaultNodes);
+ mockSdk.exo.experience.getNode.mockImplementation(
+ (id: string) => defaultNodes.find((n) => n.id === id) ?? null
+ );
+ });
+
+ 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();
+ });
+
+ it('applies a one-click fix via setContentProperty and re-audits', async () => {
+ const user = userEvent.setup();
+ // A node whose alt text has stray whitespace yields a finding with a
+ // "Trim whitespace" fix. setContentProperty trims it and clears the finding.
+ const setContentProperty = vi.fn().mockResolvedValue(undefined);
+ let altValue = ' spaced alt ';
+ const fixNode = {
+ id: 'hero',
+ nodeType: 'Component',
+ onChange: vi.fn().mockReturnValue(vi.fn()),
+ getProperties: vi.fn().mockImplementation(() =>
+ Promise.resolve([
+ { key: 'image', area: 'content', value: { sys: { id: 'asset-1' } } },
+ { key: 'altText', area: 'content', value: altValue },
+ ])
+ ),
+ setContentProperty: vi.fn().mockImplementation(async (key: string, value: string) => {
+ if (key === 'altText') altValue = value;
+ return setContentProperty(key, value);
+ }),
+ };
+ mockSdk.exo.experience.getRootNodes.mockReturnValue([fixNode]);
+ mockSdk.exo.experience.getNode.mockReturnValue(fixNode);
+
+ const { getAllByTestId, queryByText } = render();
+
+ await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0));
+ const fixButton = within(getAllByTestId('finding')[0]).getByText('Trim whitespace');
+ await user.click(fixButton);
+
+ expect(setContentProperty).toHaveBeenCalledWith('altText', 'spaced alt');
+ await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.'));
+ // Re-audit ran on the trimmed value: the whitespace finding is gone.
+ await waitFor(() => expect(queryByText('Trim whitespace')).toBeNull());
+ });
+
+ it('applies a suggested fix with the edited value via setContentProperty', async () => {
+ const user = userEvent.setup();
+ const setContentProperty = vi.fn().mockResolvedValue(undefined);
+ const metaNode = {
+ id: 'page',
+ nodeType: 'Component',
+ onChange: vi.fn().mockReturnValue(vi.fn()),
+ resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'e' }),
+ getProperties: vi.fn().mockResolvedValue([
+ { key: 'heading', area: 'content', value: 'Spring Sale' },
+ { key: 'metaTitle', area: 'content', value: '' },
+ ]),
+ setContentProperty,
+ };
+ mockSdk.exo.experience.getRootNodes.mockReturnValue([metaNode]);
+ mockSdk.exo.experience.getNode.mockReturnValue(metaNode);
+
+ const { getByTestId } = render();
+ await waitFor(() => expect(getByTestId('suggested-fix')).toBeInTheDocument());
+
+ const input = within(getByTestId('suggested-fix')).getByLabelText(
+ 'Suggested value'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('Spring Sale');
+ await user.clear(input);
+ await user.type(input, 'Spring Sale 2026');
+ await user.click(within(getByTestId('suggested-fix')).getByText('Apply'));
+
+ expect(setContentProperty).toHaveBeenCalledWith('metaTitle', 'Spring Sale 2026');
+ await waitFor(() => expect(mockSdk.notifier.success).toHaveBeenCalledWith('Fix applied.'));
+ });
+
+ it('renders Locate as disabled when selection is unsupported', async () => {
+ mockSdk.exo.experience.selection = undefined;
+ const { getAllByTestId } = render();
+ await waitFor(() => expect(getAllByTestId('finding').length).toBeGreaterThan(0));
+ const locate = within(getAllByTestId('finding')[0]).getByText('Locate').closest('button');
+ expect(locate).toBeDisabled();
+ expect(locate).toHaveAttribute('title', expect.stringContaining('not available'));
+ });
+});
diff --git a/examples/experience-auditor/src/locations/ExperienceToolbar.tsx b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx
new file mode 100644
index 0000000000..ce4c21cf7f
--- /dev/null
+++ b/examples/experience-auditor/src/locations/ExperienceToolbar.tsx
@@ -0,0 +1,235 @@
+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 { detectCapabilities } from '../audit/capabilities';
+import type { AuditFinding, AuditReport, Capabilities } 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);
+ const [capabilities, setCapabilities] = useState(() => detectCapabilities(sdk.exo));
+ useEffect(() => setCapabilities(detectCapabilities(sdk.exo)), [sdk]);
+
+ // 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.
+ // Simplification for the example: every onChange triggers a full traversal.
+ // A production app editing rapidly would debounce this (e.g. trailing 300ms)
+ // so a burst of edits collapses into a single re-audit instead of N+1 passes.
+ 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 applyWrite = useCallback(
+ async (finding: AuditFinding, propertyKey: string, value: unknown) => {
+ 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(propertyKey, value);
+ sdk.notifier.success('Fix applied.');
+ // Re-audit explicitly rather than relying on the setContentProperty
+ // write to round-trip back through onChange — keeps the panel in sync
+ // even if the host does not emit a change event for this write.
+ await audit();
+ } catch {
+ sdk.notifier.error('Could not apply the fix. Please try again.');
+ } finally {
+ setBusyFindingId(null);
+ }
+ },
+ [sdk, audit]
+ );
+
+ const handleApplyDeterministic = useCallback(
+ (finding: AuditFinding) => {
+ if (finding.fix?.kind !== 'deterministic') return;
+ void applyWrite(finding, finding.fix.propertyKey, finding.fix.value);
+ },
+ [applyWrite]
+ );
+
+ const handleApplySuggested = useCallback(
+ (finding: AuditFinding, value: string) => {
+ if (finding.fix?.kind !== 'suggested') return;
+ void applyWrite(finding, finding.fix.propertyKey, value);
+ },
+ [applyWrite]
+ );
+
+ 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' && capabilities.selection;
+
+ 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..5ac3b09bf3
--- /dev/null
+++ b/examples/experience-auditor/test/mocks/mockSdk.ts
@@ -0,0 +1,76 @@
+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),
+ resolveEntryBinding: vi.fn().mockResolvedValue({ entryId: 'mock-entry' }),
+ 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,
+ },
+});