# PIE Assessment Toolkit

**Independent, composable services** for coordinating tools, accommodations, and item players in assessment applications.

This is not an opinionated framework or monolithic "player" - it's a toolkit that solves specific problems through centralized service management.

## What's New: ToolkitCoordinator

✨ **Centralized Service Management**: The new `ToolkitCoordinator` provides a single entry point for all toolkit services, simplifying initialization and configuration.

**Before** (scattered services):
```typescript
// Create 5+ services independently
const ttsService = new TTSService();
const toolCoordinator = new ToolCoordinator();
const highlightCoordinator = new HighlightCoordinator();
const catalogResolver = new AccessibilityCatalogResolver([...]);
// Missing: ElementToolStateStore

await ttsService.initialize(new BrowserTTSProvider());
ttsService.setCatalogResolver(catalogResolver);

// Pass all services separately
player.ttsService = ttsService;
player.toolCoordinator = toolCoordinator;
// ...
```

**After** (coordinator orchestrates):
```typescript
// Create one coordinator with configuration
const toolkitCoordinator = new ToolkitCoordinator({
  assessmentId: 'my-assessment',
  tools: {
    providers: {
      textToSpeech: { enabled: true, backend: 'browser' },
      calculator: { enabled: true }
    },
    placement: {
      section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
      item: ['calculator', 'textToSpeech', 'answerEliminator'],
      passage: ['textToSpeech']
    }
  }
});

// Pass single coordinator to section-player through runtime
player.runtime = { ...(player.runtime ?? {}), coordinator: toolkitCoordinator };
```

## What Does It Solve?

- **Centralized service management**: One coordinator owns all toolkit services
- **Tool coordination**: z-index management, visibility state, element-level state
- **Accommodation support**: IEP/504 tool configuration logic
- **TTS + annotation coordination**: Prevent conflicts between highlights
- **Event communication**: Standard contracts between components
- **Accessibility theming**: Consistent high-contrast, font sizing
- **State separation**: Ephemeral tool state separate from persistent session data

## Instrumentation and Observability

Toolkit instrumentation is provider-agnostic and additive. It uses the shared
`InstrumentationProvider` contract from `@pie-players/pie-players-shared`.

### Injection Path

When toolkit is hosted by section/assessment player flows, the canonical
provider path is the item-player loader config:

- `runtime.player.loaderConfig.instrumentationProvider`

### Semantics

- With `trackPageActions: true`, missing/`undefined` providers use the default New Relic provider path.
- `instrumentationProvider: null` explicitly disables instrumentation.
- Invalid provider objects are ignored (optional debug warning), also no-op.
- Existing `item-player` behavior remains the compatibility anchor.
- Debug overlays can consume the same stream by composing providers with
  `CompositeInstrumentationProvider` (for example New Relic + debug panel).
- Toolkit telemetry forwarding uses the same provider path, so tool/backend
  instrumentation is sent to production providers and is visible in debug panel
  overlays.

### Toolkit-Owned Canonical Event Stream

- `pie-toolkit-runtime-owned`
- `pie-toolkit-runtime-inherited`
- `pie-toolkit-ready`
- `pie-toolkit-section-ready`
- `pie-toolkit-framework-error`

Toolkit tool/backend operational stream:

- `pie-tool-init-start|success|error`
- `pie-tool-backend-call-start|success|error`
- `pie-tool-library-load-start|success|error`

Ownership boundary: toolkit emits toolkit lifecycle semantics only. Section and
assessment semantic streams stay in their own layers to avoid overlap. Bridge
dedupe is a safety net, not a substitute for clear ownership.

## Architecture Overview

See the [ToolkitCoordinator section in the architecture overview](../../docs/architecture/architecture.md#toolkitcoordinator-centralized-service-management) for the current design documentation.

### Core Principles

1. **Centralized Coordination**: ToolkitCoordinator orchestrates all services
2. **Composable Services**: Import only what you need (or use coordinator for convenience)
3. **No Framework Lock-in**: Works with any JavaScript framework
4. **Product Control**: Products control navigation, persistence, layout, backend
5. **Standard Contracts**: Well-defined event types for component communication
6. **Element-Level Granularity**: Tool state tracked per PIE element, not per item
7. **State Separation**: Tool state (ephemeral) separate from PIE session data (persistent)

## Configuration tiers: easy attribute + sophisticated `runtime`

This package and `@pie-players/pie-section-player` follow a deliberate
two-tier configuration model. The same knob can usually be set in either
tier; the choice is about ergonomics, not capability.

### When to use each tier

- **Easy tier — top-level CE attributes / properties.** Use these for the
  common cases that are static for the lifetime of the player or that hosts
  want to set declaratively in HTML / templating frameworks. Example:

  ```html
  <pie-assessment-toolkit
    assessment-id="my-assessment"
    section-id="s-1"
    tool-config-strictness="warn"
  ></pie-assessment-toolkit>
  ```

- **Sophisticated tier — passing a constructed `ToolkitCoordinator` (or a
  `runtime` object on consumer CEs).** Use this for advanced cases: composed
  configuration, dynamic overrides, runtime mutation, fields without a
  tier-1 attribute, or anything that benefits from being a single typed
  object passed by reference. Example:

  ```ts
  const coordinator = new ToolkitCoordinator({
    assessmentId: "my-assessment",
    toolConfigStrictness: "warn",
    tools: {
      providers: { calculator: { enabled: true } },
      placement: { item: ["calculator", "textToSpeech"] },
    },
  });
  el.runtime = { ...(el.runtime ?? {}), coordinator };
  ```

### Naming rule

Top-level attributes use kebab-case (`assessment-id`,
`tool-config-strictness`). Section-player runtime configuration is grouped
under the `runtime` object instead of duplicated as top-level props.

### Precedence rule

The configuration object owns runtime fields. Section-player layout attributes
cover identity, layout, diagnostics, and callback/event convenience:

1. Use `runtime.<key>` for player, tool, accessibility, coordinator, env,
   isolation, and runtime factories.
2. Use top-level layout attributes for section identity and layout controls.
3. Use documented defaults when neither is provided.

### Canonical tier-1 attribute set

The tier-1 attribute set is the same shape across
`pie-assessment-toolkit`, `pie-section-player-base`, and the
`pie-section-player-*` layout elements (locked in M5). Every tier-1
surface obeys the strict mirror rule:

```
kebab-attribute  ↔  camelCaseProp  ↔  runtime.<sameCamelCaseKey>
```

Common members include:

- Identity: `assessment-id`, `section-id`, `attempt-id`
- Runtime config on section-player CEs: `runtime`
- Toolkit-only object properties: `tools`, `tool-registry`, `coordinator`,
  `accessibility`
- Diagnostics: `tool-config-strictness`, `debug`. Framework-error
  delivery is via the canonical `onFrameworkError` callback prop and the
  `framework-error` DOM event dispatched on the layout CE host.

Documented exceptions to the mirror rule:

- Identity (`section-id`, `attempt-id`, `section`): per-attempt host
  state, not configuration.
- Layout-only shell knobs on the section-player layout CEs
  (`show-toolbar`, `toolbar-position`, `narrow-layout-breakpoint`,
  `split-pane-collapse-strategy`): layout-CE rendering concerns.
- Per-region toolbar tool placement: hosts populate
  `tools.placement.item` / `tools.placement.passage` (object form) or
  `runtime.tools.placement.{item,passage}` directly.
- Runtime-only keys on the section-player layout CEs
  (`createSectionController`, `isolation`): accepted only via
  `runtime.<key>`. `<pie-assessment-toolkit>`
  itself keeps `createSectionController` and `isolation` as JS-only
  props (no kebab-attribute surface): section-player layouts forward
  `runtime.isolation` and `runtime.createSectionController` to the
  wrapped toolkit via property bindings; standalone hosts that need
  to override coordinator inheritance should pass an explicit
  `coordinator={...}` instead.

### When to add a tier-1 attribute

Add a tier-1 attribute only if all of the following hold:

- It is a common case that hosts set without composing a `ToolkitCoordinator`
  / `runtime` object.
- Its value is a primitive or small typed object that round-trips through
  HTML attributes (string, boolean-like, number; structured data passes via
  property assignment).
- It exists on every CE that conceptually owns the same knob, or has a
  deliberate documented exclusion.

Otherwise expose it through the configuration object only.

## Quick Start

### Option 1: Use ToolkitCoordinator (Recommended)

```typescript
import { ToolkitCoordinator } from '@pie-players/pie-assessment-toolkit';

// Create coordinator with configuration
const coordinator = new ToolkitCoordinator({
  assessmentId: 'demo-assessment',
  tools: {
    providers: {
      textToSpeech: { enabled: true, backend: 'browser', defaultVoice: 'en-US' },
      calculator: { enabled: true }
    },
    placement: {
      section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
      item: ['calculator', 'textToSpeech', 'answerEliminator'],
      passage: ['textToSpeech']
    }
  },
  accessibility: {
    catalogs: assessment.accessibilityCatalogs || [],
    language: 'en-US'
  }
});

// Pass to section player
const player = document.getElementById('player');
player.runtime = { ...(player.runtime ?? {}), coordinator };

// Access services directly if needed
const ttsService = coordinator.ttsService;
const toolState = coordinator.elementToolStateStore.getAllState();
```

### Controller Event Subscriptions (Helper First)

For host-side session/progress logic, prefer helper subscriptions over the generic filter API. Subscriptions follow the toolkit's *active section cohort* automatically — a single `subscribe*` call survives navigation between sections without re-wiring:

```typescript
const unsubscribeItem = coordinator.subscribeItemEvents({
  itemIds: ['item-1', 'item-2'],
  listener: (event) => {
    // item-selected, item-session-data-changed, item-complete-changed, ...
  }
});

const unsubscribeSection = coordinator.subscribeSectionLifecycleEvents({
  listener: (event) => {
    // section-loading-complete, section-items-complete-changed, section-error, ...
  }
});

// cleanup
unsubscribeItem?.();
unsubscribeSection?.();
```

Behavior pins (PIE-512 Phase D):

- Subscribe **after** the first `getOrCreateSectionController(...)` resolves; calling subscribe before any cohort exists throws.
- On every cohort transition (navigation, fresh `getOrCreateSectionController` for a new section), the listener is automatically migrated to the new controller and receives a snapshot replay (`content-loaded` × N then `section-loading-complete`) in the same order a fresh subscriber would have seen.
- Subscribing the **same listener function** twice replaces the first subscription (filter args from the second call win).
- A listener that throws is caught and `console.warn`-logged; the throw does not interrupt fan-out to other listeners.

Use `subscribeSectionEvents(...)` when you need advanced/custom filtering mixes. Section-scoped events do not carry item IDs, so pairing them with `itemIds` filters will not match.

To persist or snapshot an inactive section, use `coordinator.getSectionController({ sectionId, attemptId })` — that lookup is by id and is unaffected by the active-cohort behavior described above.

#### Migrating from `<0.3.35` (BREAKING — pre-1.0)

`0.3.35` is the first release where `subscribeSectionEvents` (and its two helper wrappers `subscribeItemEvents` / `subscribeSectionLifecycleEvents`) follows the toolkit's *active section cohort* automatically. The on-the-wire shape of the subscription args object changed.

If your host code looked like this:

```typescript
const unsub = coordinator.subscribeItemEvents({
  sectionId: 'section-1',
  attemptId: 'attempt-1',
  listener: handleEvent,
});
```

Update it to drop `sectionId` / `attemptId`:

```typescript
const unsub = coordinator.subscribeItemEvents({
  listener: handleEvent,
});
```

What this means in practice for typed integrations:

- **TypeScript breaking change.** `SectionEventSubscriptionArgs`, `SectionItemEventSubscriptionArgs`, and `SectionScopedEventSubscriptionArgs` no longer declare `sectionId?` / `attemptId?` properties. Any host that imports these arg types directly and passes those keys will fail to compile after upgrade. **Action required.**
- **Runtime is tolerant.** The runtime silently ignores extra unknown properties, so an untyped or lightly-typed call site that still passes `sectionId` / `attemptId` continues to work without source changes. The args have **no effect** at runtime — the subscription always follows the active cohort.
- **New precondition.** `subscribe*` now throws if no active section cohort exists. Subscribe **after** the first `getOrCreateSectionController(...)` resolves. Subscribing on `toolkit-ready` alone is no longer sufficient — though in practice the section player emits `toolkit-ready` *after* its first `getOrCreateSectionController(...)` resolves, so a `toolkit-ready` anchor is safe in section-player hosts.
- **Cohort migration is automatic.** If your wrapper previously re-subscribed on every navigation to keep listeners alive across sections, that wiring is no longer needed (and should be removed). A single subscribe call after the first controller-resolve is now enough — the listener migrates automatically and is replayed the new cohort's snapshot on every transition.
- **Watch for double-replay if you re-subscribe on every `toolkit-ready`.** Hosts that detached and re-subscribed on every `toolkit-ready` event (the correct pre-Phase D pattern, since each subscription was pinned to a `sectionId`) will now observe **two snapshot replays per navigation**: one delivered automatically when Phase D migrates the existing listener to the new active cohort, and a second when the manual re-subscribe attaches a fresh listener that replays again. Listener handlers that are not strictly idempotent will fire twice — analytics `pageAction`s, non-Set counters, side-effecting hydration. The fix is a one-line guard (`if (this.controllerUnsubscribe) return;`) so the subscribe runs only on the first `toolkit-ready`.
- **For intentionally-pinned subscriptions to inactive sections** (e.g. a host UI that wants to keep watching section A while the user views section B), the helper API does not support that pattern by design. Use `coordinator.getSectionController({ sectionId, attemptId })` and subscribe directly on the controller handle (`controller.subscribe?.(...)`) — that binding is pinned to one controller instance and does not migrate.

If your local types were hand-rolled structural copies of the public arg types (e.g. an Angular wrapper duplicating the shape rather than importing the package types), `sectionId` / `attemptId` keys will compile but are dead code at runtime — recommend dropping them as part of the upgrade.

#### Pre-Phase D vs Phase D wrapper pattern

```typescript
// BEFORE (pre-Phase D): rebind for every section change because the
// subscription was pinned to a sectionId.
public handleToolkitReady(event: Event): void {
  const coordinator = (event as CustomEvent).detail?.coordinator;
  if (!coordinator) return;
  this.controllerUnsubscribe?.();  // detach prior pin
  const itemUnsub = coordinator.subscribeItemEvents({
    sectionId: this.sectionId,
    listener: handleItemEvent,
  });
  const sectionUnsub = coordinator.subscribeSectionLifecycleEvents({
    sectionId: this.sectionId,
    listener: handleSectionEvent,
  });
  this.controllerUnsubscribe = () => { itemUnsub?.(); sectionUnsub?.(); };
}
```

```typescript
// AFTER (Phase D): subscribe once; the listener follows the active
// cohort across all subsequent navigation.
public handleToolkitReady(event: Event): void {
  const coordinator = (event as CustomEvent).detail?.coordinator;
  if (!coordinator) return;
  this.toolkitCoordinator = coordinator;
  if (this.controllerUnsubscribe) return; // already subscribed; do nothing on re-fire
  const itemUnsub = coordinator.subscribeItemEvents({
    listener: handleItemEvent,
  });
  const sectionUnsub = coordinator.subscribeSectionLifecycleEvents({
    listener: handleSectionEvent,
  });
  this.controllerUnsubscribe = () => { itemUnsub?.(); sectionUnsub?.(); };
}
```

### Option 2: Create Services Manually (Advanced)

```typescript
import {
  TTSService,
  BrowserTTSProvider,
  ToolCoordinator,
  HighlightCoordinator,
  AccessibilityCatalogResolver,
  ElementToolStateStore
} from '@pie-players/pie-assessment-toolkit';

// Initialize each service independently
const ttsService = new TTSService();
const toolCoordinator = new ToolCoordinator();
const highlightCoordinator = new HighlightCoordinator();
const elementToolStateStore = new ElementToolStateStore();
const catalogResolver = new AccessibilityCatalogResolver([], 'en-US');

await ttsService.initialize(new BrowserTTSProvider());
ttsService.setCatalogResolver(catalogResolver);

// Pass services individually
player.ttsService = ttsService;
player.toolCoordinator = toolCoordinator;
// ...
```

## Tool Configuration Model

The toolkit uses one canonical `tools` model with three concerns:

- `policy`: allow/block constraints (global gates)
- `placement`: where tools appear (`assessment`, `section`, `item`, `passage`, `rubric`, plus custom registered levels)
- `providers`: provider/runtime options (calculator, textToSpeech, etc.)

Example:

```typescript
tools: {
  policy: {
    allowed: ['calculator', 'textToSpeech', 'answerEliminator', 'graph', 'periodicTable'],
    blocked: ['graph']
  },
  placement: {
    assessment: [],
    section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
    item: ['calculator', 'textToSpeech', 'answerEliminator'],
    passage: ['textToSpeech'],
    rubric: []
  },
  providers: {
    calculator: { authFetcher: async () => ({ apiKey: '...' }) },
    textToSpeech: { enabled: true, backend: 'browser', defaultVoice: 'en-US' }
  }
}
```

### Scope and Lifecycle

The runtime still distinguishes between contextual (`item`/`passage`) and section-wide tools:

Tool instances use structured IDs so scope is explicit:

```text
<toolId>:<scopeLevel>:<scopeId>[:inline]
```

Examples:
- `calculator:section:section-1`
- `calculator:item:item-42`
- `textToSpeech:passage:passage-1`
- `highlighter:rubric:rubric-3`

### Item-Level Tools (`tools.placement.item`)

Tools that operate **within the context of a specific question/item**:

```typescript
tools: {
  placement: {
    item: ['calculator', 'textToSpeech', 'answerEliminator']
  }
}
```

**Characteristics:**
- **Scope**: Bound to a specific item's DOM context
- **Lifecycle**: Instance created/destroyed as you navigate between items
- **State**: Isolated per-item (eliminations for Q5 don't affect Q6)
- **UI Pattern**: Inline buttons in question headers/toolbars
- **State Persistence**: Tracked per-item in ElementToolStateStore

**Available Item-Level Tools:**
- **TTS (Text-to-Speech)**: Reads the specific question/passage text
- **Answer Eliminator**: Strikes through answer choices for that question
- **Highlighter**: Highlights text within the item (future)

**Example Use Case:**
A student uses answer eliminator on Question 3 to cross out choices B and D. When they navigate to Question 4, they see fresh, uneliminated choices. When they return to Question 3, their eliminations are restored.

### Section-Level Tools (`tools.placement.section`)

Tools that **float above the entire assessment** and persist across questions:

```typescript
tools: {
  placement: {
    section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler', 'theme']
  },
  providers: {
    calculator: {
      enabled: true,
      authFetcher: async () => { /* ... */ }
    }
  }
}
```

**Characteristics:**
- **Scope**: Section-wide, shared across all questions
- **Lifecycle**: Single instance initialized for entire section
- **State**: Persistent (calculator history remains as you navigate)
- **UI Pattern**: Draggable floating panels/overlays with z-index management
- **State Persistence**: Global state maintained throughout section

**Available Floating Tools:**
- **Calculator**: Scientific/graphing calculator with computation history
- **Graph**: Graphing tool for plotting functions
- **Periodic Table**: Interactive periodic table reference
- **Protractor**: Angle measurement tool
- **Ruler**: Linear measurement tool (metric/imperial)
- **Line Reader**: Reading guide/masking overlay
- **Magnifier**: Screen magnification tool
- **Color Scheme**: High-contrast color adjustments

**Example Use Case:**
A student opens the calculator on Question 2, computes 45 × 12 = 540. They navigate to Question 7, and the calculator still shows their computation history. They can reference previous calculations across multiple questions without losing context.

### When to Use Each

Use **item-level tools** when:
- Tool needs to read/interact with specific question content
- State should be isolated per-question
- Tool appears inline with the question (space-efficient)
- Tool behavior is contextual to the current item

Use **floating tools** when:
- Tool is a general-purpose utility used across multiple questions
- State should persist across navigation
- Tool needs independent positioning and sizing
- Tool provides reference information or computation capability

### Configuration Example

Complete example showing both types:

```typescript
const coordinator = new ToolkitCoordinator({
  assessmentId: 'math-exam',
  tools: {
    placement: {
      // Contextual placement
      item: ['calculator', 'textToSpeech', 'answerEliminator'],
      passage: ['textToSpeech'],
      // Section-level utilities
      section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler', 'theme']
    },
    providers: {
      calculator: {
        enabled: true,
        authFetcher: async () => {
          const response = await fetch('/api/tools/desmos/auth');
          return response.json();
        }
      },
      textToSpeech: { enabled: true, backend: 'browser' }
    }
  },
  accessibility: {
    catalogs: [],
    language: 'en-US'
  }
});
```

**Simple Default (All Tools Enabled):**

For most use cases, simply enable all available tools:

```typescript
const coordinator = new ToolkitCoordinator({
  assessmentId: 'my-assessment',
  tools: {
    placement: {
      section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
      item: ['calculator', 'textToSpeech', 'answerEliminator'],
      passage: ['textToSpeech']
    },
    providers: {
      calculator: { enabled: true },
      textToSpeech: { enabled: true, backend: 'browser' }
    }
  }
});
```

The ToolkitCoordinator handles all internal complexity (service initialization, provider management, state coordination). The only special configuration is `authFetcher` for Desmos calculator (optional - falls back to local calculator if not provided).

### Minimal Server-Backed TTS Config

For Polly/Google server-backed TTS, the provider config supports a minimal form.
Common options are defaulted so you can start with:

```typescript
tools: {
  providers: {
    textToSpeech: {
      enabled: true,
      backend: 'polly'
    }
  }
}
```

By default, server-backed TTS resolves:

- `apiEndpoint: '/api/tts'`
- `transportMode: 'pie'`
- `endpointValidationMode: 'voices'`

You can still set `apiEndpoint` explicitly when your host route is not `/api/tts`.

### Inline TTS Speed Options

Inline TTS speed buttons are configurable via `speedOptions` in provider settings.

```typescript
tools: {
  providers: {
    textToSpeech: {
      enabled: true,
      backend: "browser",
      settings: {
        speedOptions: [2, 1.25, 1.5] // rendered in this order
      }
    }
  }
}
```

Hosts that need semantic UI copy can use object-form options. The `rate` still
drives playback and provider mapping; `label` / `ariaLabel` only change visible
and accessible button text.

```typescript
tools: {
  providers: {
    textToSpeech: {
      enabled: true,
      backend: "server",
      serverProvider: "custom",
      settings: {
        speedOptions: [
          { rate: 0.8, label: "Slow", ariaLabel: "Slow speed" },
          { rate: 1.5, label: "Fast", ariaLabel: "Fast speed" }
        ]
      }
    }
  }
}
```

`speedOptions` semantics:

- Omitted or non-array: default speed buttons are shown (`0.8x`, `1.25x`).
- Explicit empty array (`[]`): hide all speed buttons.
- Invalid-only arrays (for example `["fast", -1, 1]`): fall back to defaults.
- Valid numeric values are deduplicated and keep first-seen order.
- Object-form entries use the same numeric validation/deduping by `rate`.
- Missing labels fall back to numeric text like `1.5x`; missing `ariaLabel`
  falls back to a matching accessible name like `Fast speed`.
- `1` is excluded (normal speed is already available by toggling active speed off).

### Runtime Fallback: Server TTS -> Browser TTS

When server-backed playback fails at runtime (for example `503`, network outage,
or synthesized asset fetch failure), `TTSService` now performs a one-time
runtime fallback for that session:

1. Switches provider from server-backed implementation to browser speech synthesis.
2. Rebinds highlight callbacks to the browser provider.
3. Retries the same `speak()` request once.

This keeps the inline/passage TTS controls usable during transient backend
incidents without requiring host-side reconfiguration.

Telemetry emitted for observability:

- `pie-tool-runtime-fallback` (fallback switch succeeded)
- `pie-tool-runtime-fallback-error` (fallback switch failed)

`provider.runtime.authFetcher` is optional. Add it only when your host environment
requires runtime auth material for TTS requests:

```typescript
tools: {
  providers: {
    textToSpeech: {
      enabled: true,
      backend: 'polly',
      apiEndpoint: '/api/tts',
      provider: {
        runtime: {
          authFetcher: async () => {
            const response = await fetch('/api/tts/auth');
            return response.json();
          }
        }
      }
    }
  }
}
```

### Custom Transport via Server Proxy (SC-style)

For custom backends that return URL assets (for example `{ audioContent, word }`),
prefer a host-owned proxy endpoint so secrets never ship to the browser.

```typescript
tools: {
  providers: {
    textToSpeech: {
      enabled: true,
      backend: "server",
      serverProvider: "custom",
      transportMode: "custom",
      endpointMode: "rootPost",
      endpointValidationMode: "none",
      apiEndpoint: "/api/tts/sc",
      speedRate: "medium",
      lang_id: "en-US",
      cache: true
    }
  }
}
```

Recommended host boundary:

- Browser calls local proxy (`/api/tts/sc`) only.
- Proxy route reads required server env vars (no defaults) and signs/attaches auth
  upstream.
- Browser never receives shared secret, API key, or signing material.

SchoolCity is used as a host-configured integration example for custom transport.
Toolkit defaults still remain browser/standard providers unless the host explicitly
configures custom server-backed TTS.

## Test Attempt Session Adapter (pie backend)

The toolkit exposes a canonical `TestAttemptSession` runtime and a deterministic adapter for pie backend activity payloads from `../../kds/pie-api-aws`.

```typescript
import {
  mapActivityToTestAttemptSession,
  toItemSessionsRecord,
  buildActivitySessionPatchFromTestAttemptSession
} from "@pie-players/pie-assessment-toolkit";

const testAttemptSession = mapActivityToTestAttemptSession({
  activityDefinition,
  activitySession
});

// Use in section-player handoff (same item session shape as item players expect)
const itemSessions = toItemSessionsRecord(testAttemptSession);

// Host-owned backend persistence payload
const patch = buildActivitySessionPatchFromTestAttemptSession(testAttemptSession);
```

### Integration Boundary

- `@pie-players/pie-section-player` stays backend-agnostic and emits session/state changes.
- Host applications own backend I/O to pie backend (`../../kds/pie-api-aws`).
- Hosts decide persistence policy (immediate, debounced, checkpoint, submit).

### Section session API (controller + persistence)

For section-level session flows, the toolkit supports two complementary APIs:

- Persistence hook: `createSectionSessionPersistence(context, defaults)` for load/save/clear orchestration
- Direct controller API: `getSession()`, `applySession(session, { mode })`, `updateItemSession(itemId, detail)`

The persistence strategy works with the same `SectionControllerSessionState` shape exposed by the controller, so hosts can choose bulk restore (`applySession`) and fine-grained updates (`updateItemSession`) without internal runtime coupling.

## Implementation Status

### ✅ Toolkit Services

- **ToolkitCoordinator**: ⭐ NEW - Centralized service orchestration
- **ElementToolStateStore**: ⭐ NEW - Element-level ephemeral tool state management
- **ToolRegistry**: ⭐ NEW - Registry-based tool management with QTI 3.0 PNP support
- **ToolPolicyEngine**: QTI 3.0 Personal Needs Profile and host policy decisions via registry-backed policy sources
- **ToolCoordinator**: Manages z-index layering and visibility for floating tools
- **HighlightCoordinator**: Separate highlight layers for TTS (temporary) and annotations (persistent)
- **TTSService**: Text-to-speech with QTI 3.0 catalog support
- **AccessibilityCatalogResolver**: QTI 3.0 accessibility catalog management
- **SSMLExtractor**: Automatic extraction of embedded `<speak>` tags
- **ThemeProvider**: Consistent accessibility theming

### ✅ QTI 3.0 Standard Access Features

- **95+ Standardized Features**: Complete QTI 3.0 / IMS AfA 3.0 accessibility features
- **9 Feature Categories**: Visual, auditory, motor, cognitive, reading, navigation, linguistic, assessment
- **Example Configurations**: Illustrative PNP profile examples (low vision, dyslexia, ADHD, etc.)
- **Tool Mappings**: All 12 default tools map to standard QTI 3.0 features

### ✅ Section Player Integration

The toolkit integrates seamlessly with the **PIE Section Player**:

- **Primary Interface**: Section player is the main integration point
- **Default Coordinator**: Creates ToolkitCoordinator automatically if not provided
- **Automatic SSML Extraction**: Extracts embedded `<speak>` tags from passages and items
- **Catalog Lifecycle**: Manages item-level catalogs automatically
- **Service Coordination**: All toolkit services work together automatically

## ToolkitCoordinator API

### Configuration

```typescript
export interface ToolkitCoordinatorConfig {
  assessmentId: string;  // Required: unique assessment identifier
  tools?: {
    policy?: {
      allowed?: string[];
      blocked?: string[];
    };
    placement?: {
      assessment?: string[];
      section?: string[];
      item?: string[];
      passage?: string[];
      rubric?: string[];
    };
    providers?: {
      textToSpeech?: {
        enabled?: boolean;
        backend?: 'browser' | 'polly' | 'google' | 'server';
        defaultVoice?: string;
        rate?: number;
      };
      calculator?: {
        enabled?: boolean;
        authFetcher?: () => Promise<Record<string, unknown>>;
      };
    };
  };
  toolRegistry?: ToolRegistry | null;
  accessibility?: {
    catalogs?: any[];
    language?: string;
  };
}
```

### Methods

```typescript
// Get all services as a bundle
const services = coordinator.getServiceBundle();
// Returns: { ttsService, toolCoordinator, highlightCoordinator, elementToolStateStore, catalogResolver }

// Tool configuration
coordinator.isToolEnabled('textToSpeech');  // Check if tool is enabled
coordinator.getToolConfig('textToSpeech');  // Get tool-specific config
coordinator.updateToolConfig('textToSpeech', { rate: 1.5 });  // Update tool config
```

### Direct Service Access

All services are public properties for direct access:

```typescript
coordinator.ttsService              // TTSService instance
coordinator.toolCoordinator         // ToolCoordinator instance
coordinator.highlightCoordinator    // HighlightCoordinator instance
coordinator.elementToolStateStore   // ElementToolStateStore instance
coordinator.catalogResolver         // AccessibilityCatalogResolver instance
```

## ElementToolStateStore API

The `ElementToolStateStore` manages ephemeral tool state at the element level using globally unique composite keys.

### Key Concepts

- **Global Element ID**: Composite key format: `${assessmentId}:${sectionId}:${itemId}:${elementId}`
- **Element-Level Granularity**: State tracked per PIE element (not per item)
- **Ephemeral State**: Tool state is client-only, separate from PIE session data
- **Cross-Section Persistence**: State persists when navigating between sections

### ID Utilities

```typescript
// Generate global element ID
const globalElementId = store.getGlobalElementId(
  'demo-assessment',
  'section-1',
  'question-1',
  'mc1'
);
// Returns: "demo-assessment:section-1:question-1:mc1"

// Parse global element ID
const components = store.parseGlobalElementId(globalElementId);
// Returns: { assessmentId, sectionId, itemId, elementId }
```

### CRUD Operations

```typescript
// Set state for a tool on an element
store.setState(globalElementId, 'answerEliminator', {
  eliminatedChoices: ['choice-a', 'choice-c']
});

// Get state for a specific tool
const state = store.getState(globalElementId, 'answerEliminator');

// Get all tool states for an element
const elementState = store.getElementState(globalElementId);

// Get all states across all elements
const allState = store.getAllState();
```

### Cleanup Operations

```typescript
// Clear state for a specific element
store.clearElement(globalElementId);

// Clear state for a specific tool across all elements
store.clearTool('answerEliminator');

// Clear all elements in a specific section
store.clearSection('demo-assessment', 'section-1');

// Clear all state
store.clearAll();
```

### Persistence Integration

```typescript
// Set callback for persistence (e.g., localStorage)
store.setOnStateChange((state) => {
  localStorage.setItem('tool-state', JSON.stringify(state));
});

// Load state from persistence
const saved = localStorage.getItem('tool-state');
if (saved) {
  store.loadState(JSON.parse(saved));
}
```

### Reactivity

```typescript
// Subscribe to state changes
const unsubscribe = store.subscribe((state) => {
  console.log('State changed:', state);
});

// Unsubscribe when done
unsubscribe();
```

## Service APIs

### TTSService

```typescript
const ttsService = new TTSService();

// Initialize with provider
await ttsService.initialize(new BrowserTTSProvider());

// Set catalog resolver for SSML support
ttsService.setCatalogResolver(catalogResolver);

// Playback
await ttsService.speak('Read this text', {
  catalogId: 'prompt-001',
  language: 'en-US'
});

// Controls
ttsService.pause();
ttsService.resume();
ttsService.stop();

// Settings
await ttsService.updateSettings({
  rate: 1.5,
  voice: 'Matthew'
});
```

### ToolCoordinator

```typescript
const toolCoordinator = new ToolCoordinator();

// Register tools
toolCoordinator.registerTool('calculator', 'Calculator', element);

// Manage visibility
toolCoordinator.showTool('calculator');
toolCoordinator.hideTool('calculator');
toolCoordinator.toggleTool('calculator');

// Z-index management
toolCoordinator.bringToFront(element);

// Check state
const isVisible = toolCoordinator.isToolVisible('calculator');
```

### HighlightCoordinator

```typescript
const highlightCoordinator = new HighlightCoordinator();

// TTS highlights (temporary)
highlightCoordinator.highlightTTSWord(textNode, start, end);
highlightCoordinator.highlightTTSSentence([range1, range2]);
highlightCoordinator.clearTTS();

// Annotation highlights (persistent)
const id = highlightCoordinator.addAnnotation(range, 'yellow');
highlightCoordinator.removeAnnotation(id);
```

### AccessibilityCatalogResolver

```typescript
const resolver = new AccessibilityCatalogResolver(
  assessment.accessibilityCatalogs,
  'en-US'
);

// Add item-level catalogs
resolver.addItemCatalogs(item.accessibilityCatalogs);

// Get alternative representation
const alternative = resolver.getAlternative('prompt-001', {
  type: 'spoken',
  language: 'en-US'
});

// Clear item catalogs when navigating away
resolver.clearItemCatalogs();
```

### SSMLExtractor

```typescript
const extractor = new SSMLExtractor();

// Extract from item config
const result = extractor.extractFromItemConfig(item.config);

// Update item with cleaned config
item.config = result.cleanedConfig;
item.config.extractedCatalogs = result.catalogs;

// Register with catalog resolver
catalogResolver.addItemCatalogs(result.catalogs);
```

## Integration with Section Player

The section player provides automatic ToolkitCoordinator integration:

```html
<pie-section-player id="player"></pie-section-player>

<script type="module">
  import { ToolkitCoordinator } from '@pie-players/pie-assessment-toolkit';

  // Create coordinator
  const coordinator = new ToolkitCoordinator({
    assessmentId: 'my-assessment',
    tools: {
      providers: { textToSpeech: { enabled: true, backend: 'browser' } },
      placement: {
        section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
        item: ['calculator', 'textToSpeech', 'answerEliminator'],
        passage: ['textToSpeech']
      }
    }
  });

  // Pass to player
  const player = document.getElementById('player');
  player.runtime = { ...(player.runtime ?? {}), coordinator };
  player.section = mySection;

  // Player automatically:
  // - Extracts services from coordinator
  // - Generates section ID
  // - Provides runtime context to child components
  // - Manages SSML extraction
  // - Handles catalog lifecycle
</script>
```

### Runtime Context Contract

The toolkit now exports a shared context key used by section-player and toolkit
components:

```typescript
import {
  assessmentToolkitRuntimeContext,
  type AssessmentToolkitRuntimeContext
} from "@pie-players/pie-assessment-toolkit";
```

`AssessmentToolkitRuntimeContext` carries ambient orchestration dependencies
that should not be prop-drilled through intermediate components:

- `toolkitCoordinator`
- `toolCoordinator`
- `ttsService`
- `highlightCoordinator`
- `catalogResolver`
- `elementToolStateStore`
- `assessmentId`
- `sectionId`

These runtime fields are expected to be present once the section-player
provider is established (host-supplied coordinator or lazily created by
section-player). Use explicit props/events for direct content contracts, and
use runtime context for cross-cutting orchestration scope.

### Standalone Sections (No Coordinator Provided)

If no coordinator is provided, the section player creates a default one:

```javascript
// No coordinator provided - section player creates default
player.section = mySection;

// Internally creates:
// new ToolkitCoordinator({
//   assessmentId: 'anon_...',  // auto-generated
//   tools: {
//     providers: { textToSpeech: { enabled: true, backend: 'browser' }, calculator: { enabled: true } },
//     placement: {
//       section: ['calculator', 'graph', 'periodicTable', 'protractor', 'lineReader', 'ruler'],
//       item: ['calculator', 'textToSpeech', 'answerEliminator'],
//       passage: ['textToSpeech']
//     }
//   }
// })
```

### Safe Custom Tool Configuration

Default behavior is now framework-owned: invalid tools/runtime initialization is handled in `pie-assessment-toolkit` without host try/catch.

- Framework logs a deterministic console error prefix: `[pie-framework:<kind>:<source>]`
- Framework emits a canonical `framework-error` event
- Framework renders a fallback error panel instead of a blank player
- Startup tool-config validation can surface as `kind: "coordinator-init"` when the owned coordinator construction path throws.

Use `createToolsConfig()` when you want to pre-validate and inspect diagnostics before mounting:

```typescript
import {
  createPackagedToolRegistry,
  createToolsConfig,
  ToolkitCoordinator
} from "@pie-players/pie-assessment-toolkit";

const toolRegistry = createPackagedToolRegistry();
const { config, diagnostics } = createToolsConfig({
  source: "host.bootstrap",
  strictness: "error",
  toolRegistry,
  tools: {
    providers: {
      textToSpeech: { enabled: true, backend: "browser" },
      calculator: { enabled: true }
    },
    placement: {
      item: ["calculator", "textToSpeech"]
    }
  }
});

// Fail-fast default: invalid config throws at the boundary.
const coordinator = new ToolkitCoordinator({
  assessmentId: "demo-assessment",
  toolRegistry,
  tools: config,
  toolConfigStrictness: "error"
});
```

Notes:
- `providers.textToSpeech` is the canonical TTS provider key.
- `providers.tts` is rejected by the validation contract.
- Custom tools can provide provider-level `sanitizeConfig` and `validateConfig` hooks.
- Hosts can react to framework errors via the `framework-error` DOM event,
  the `onFrameworkError(model)` callback prop, or by subscribing directly
  to the package-internal bus via
  `ToolkitCoordinator.subscribeFrameworkErrors(listener)`. The callback
  prop fires exactly once per error, regardless of wrapper depth. Filter
  by `model.kind` (e.g. `"tts-init"`, `"provider-init"`,
  `"provider-register"`) for tool- or provider-specific handling.
- See `docs/tools-and-accomodations/framework-owned-error-handling.md` for event payload and error-kind mapping details.

## Section Runtime Engine (advanced)

The toolkit exposes a layered **section runtime engine** that consolidates
runtime resolution, FSM-driven stage progression, framework-error reporting,
DOM-event fan-out, and instrumentation into a single object hosts can mount
and dispose. The engine is what `<pie-section-player-…>` and
`<pie-assessment-toolkit>` use internally, and it is also the surface
custom hosts (or alternate layout shells) consume directly.

### Two import paths

The engine ships with two deliberately separate entry points so consumers
pick the stability surface that matches their use case:

- **Stable facade — `@pie-players/pie-assessment-toolkit/runtime/engine`.**
  Narrow, semver-stable surface for hosts that want to mount, drive, and
  dispose a section runtime. Re-exports `SectionRuntimeEngine`,
  `SECTION_RUNTIME_ENGINE_KEY` (Svelte context), the cross-CE host
  context (`sectionRuntimeEngineHostContext`), and the consumer-side
  helper for that bridge (`connectSectionRuntimeEngineHostContext`).
  The cross-CE host context exposes only a lifecycle handle; controller
  methods stay on `SectionRuntimeEngine`.
- **Internal surface — `@pie-players/pie-assessment-toolkit/runtime/internal`.**
  Wider, evolving surface for advanced hosts that need to construct an
  engine manually, inspect FSM state, or build alternate fan-out paths.
  Exposes `SectionEngineCore`, the four adapter bridges
  (`createDomEventBridge`, `createFrameworkErrorBridge`,
  `createCoordinatorBridge`, `createInstrumentationBridge`),
  `FrameworkErrorBus`, cohort helpers,
  and the `resolveRuntime` / `resolveToolsConfig` /
  `resolveSectionEngineRuntimeState` helpers. Symbols here may change
  between minor versions with a changeset note.

### Lifecycle emit coordination

When `<pie-assessment-toolkit>` is nested inside a section-player layout,
the layout kernel publishes a lifecycle handle via
`sectionRuntimeEngineHostContext`. The toolkit detects that host
lifecycle owner and **suppresses its own external lifecycle DOM emits
and `onStageChange` callback** in favor of the layout CE host. From the
outside, one cohort yields one `pie-stage-change` /
`pie-loading-complete` chain on the layout CE host regardless of wrapper
depth. Controller-side
registration, content loading, session propagation, and persistence
remain toolkit-local through its own `SectionRuntimeEngine` instance.
A standalone `<pie-assessment-toolkit>` (no host context) emits from
its own engine.

**Detection.** If a custom layout shell emits two `pie-stage-change`
events per stage transition (or two `pie-loading-complete` per cohort)
on the same layout CE — typically with two distinct `detail.runtimeId`
values — the shell has not published its engine via
`sectionRuntimeEngineHostContext`, so the wrapped
`<pie-assessment-toolkit>` falls back to its standalone lifecycle emit
path. Wire the bridge as shown below.

### Common-host wiring example

Most hosts never construct the engine directly — the section-player
layout CE and the toolkit CE handle it. Use the facade only when
building an alternate layout shell (e.g. a custom kernel host). The
shape mirrors what the section-player kernel does internally:

```ts
import { ContextProvider } from "@pie-players/pie-context";
import {
  SectionRuntimeEngine,
  sectionRuntimeEngineHostContext,
} from "@pie-players/pie-assessment-toolkit/runtime/engine";
import {
  FrameworkErrorBus,
  makeCohort,
} from "@pie-players/pie-assessment-toolkit/runtime/internal";

const bus = new FrameworkErrorBus();
const engine = new SectionRuntimeEngine();

// 1. Attach to the layout CE host. `sourceCe` is stamped onto every
//    DOM event the engine dispatches and is required.
engine.attachHost({
  host: layoutHostElement,
  sourceCe: "my-custom-layout",
  frameworkErrorBus: bus,
  coordinator: toolkitCoordinator,
});

// 2. Publish a lifecycle handle on the layout CE host so any wrapped
//    <pie-assessment-toolkit> suppresses duplicate external lifecycle
//    emits. The toolkit still owns its controller registration/session
//    plumbing locally.
const engineProvider = new ContextProvider(layoutHostElement, {
  context: sectionRuntimeEngineHostContext,
  initialValue: {
    engine: {
      getRuntimeId: () => engine.getRuntimeId(),
    },
  },
});
engineProvider.connect();

// 3. (Optional) Subscribe to the structured output stream — same set
//    of outputs the DOM-event bridge fans out to the host element.
engine.subscribe((output) => {
  // tap stage transitions, readiness updates, framework errors,
  // instrumentation events
});

// 4. Drive the engine. Use real `SectionEngineInput` shapes:
const cohort = makeCohort({ sectionId, attemptId });
engine.dispatchInput({
  kind: "initialize",
  cohort,
  effectiveRuntime,
  effectiveToolsConfig,
  itemCount,
});

// On loading-progress / readiness signal updates:
engine.dispatchInput({
  kind: "update-readiness-signals",
  signals: {
    sectionReady,
    interactionReady,
    allLoadingComplete,
    runtimeError,
  },
  loadedCount,
  itemCount,
  mode: "progressive",
});

// On unmount:
engineProvider.disconnect();
engine.dispose();
```

The DOM events `pie-stage-change`, `pie-loading-complete`, and
`framework-error` are dispatched on `host` automatically by the
adapter's `dom-event-bridge`. The canonical `onFrameworkError` callback
prop and the package-internal `FrameworkErrorBus` deliver each error
exactly once regardless of wrapper depth. The `framework-error` DOM
event on the layout CE host also delivers each error exactly once: the
section-player kernel intercepts the toolkit's bubbled emit at
`<pie-section-player-base>` and calls `event.stopPropagation()`, so the
layout host sees only the canonical engine-bridge emit. Direct
listeners on `<pie-assessment-toolkit>` itself still see the toolkit's
own emit (the toolkit dispatch reaches them before the kernel listener
runs). The single-emit contract is pinned by
`packages/section-player/tests/section-player-framework-error-dual-emit.test.ts`.
The layout host emits one `framework-error` DOM event per framework error.

Hosts should listen to `pie-stage-change` (with the readiness detail also
available via the kernel's `selectReadiness()`) and `pie-loading-complete`.

## State Separation: Tool State vs Session Data

The toolkit enforces a clear separation between ephemeral tool state and persistent session data:

### Tool State (Ephemeral - ElementToolStateStore)

**Client-only**, never sent to server for scoring:

```typescript
{
  "demo-assessment:section-1:question-1:mc1": {
    "answerEliminator": {
      "eliminatedChoices": ["choice-b", "choice-d"]
    },
    "highlighter": {
      "annotations": [...]
    }
  }
}
```

**Use for:**
- Answer eliminations
- Highlighting/annotations
- Tool preferences
- UI state

### PIE Session Data (Persistent)

**Sent to server** for scoring:

```typescript
{
  "question-1": {
    "id": "session-123",
    "data": [
      { "id": "mc1", "element": "multiple-choice", "value": ["choice-a"] }
    ]
  }
}
```

**Use for:**
- Student responses
- Scoring data
- Assessment outcomes

## Examples

See the [section-demos](../../apps/section-demos/) for complete examples:

- **Three Questions Demo**: Element-level answer eliminator with state persistence
- **TTS Integration Demo**: Toolkit coordinator with TTS service
- **Paired Passages Demo**: Multi-section assessment with cross-section state

## TypeScript Support

Full TypeScript definitions included:

```typescript
import type {
  IToolkitCoordinator,
  IElementToolStateStore,
  ToolkitCoordinatorConfig,
  ToolkitServiceBundle
} from '@pie-players/pie-assessment-toolkit';
```

## Content trust boundary

The toolkit embeds item content via the underlying `pie-item-player`
custom element and renders tool icons / SSML fragments that originate
from tool configuration. Two sanitization layers apply:

- **Item / passage markup** - sanitized by default in
  `pie-item-player`. See
  [pie-item-player README](./README.md#content-trust-boundary)
  for the `trust-markup` opt-out and the `sanitizeMarkup` override.
  As a post-sanitization step, every authored `<img>` outside a `pie-*`
  custom element is wrapped in `<span class="pie-image-scroll">` so
  overwide images surface a horizontal scrollbar instead of being
  clipped by the section layout's `overflow-x: hidden` ancestors
  (PIE-94 / WCAG 1.4.10 Reflow at 400% zoom). The wrapper is
  keyboard-scrollable (`tabindex="0"`, `role="region"`) and carries
  the image's `alt` text in its `aria-label`; matching CSS lives in
  `@pie-players/pie-theme` (`components.css`). Authored `<table>`
  elements outside a `pie-*` custom element get the same treatment via
  `<div class="pie-table-scroll">` so wide data grids reflow into a
  horizontally scrollable region; the wrapper's `aria-label` is derived
  from the table's `aria-label` / `aria-labelledby` / `<caption>`.
- **Tool icons and SSML** - tool-registered icon markup is parsed and
  DOMPurified inside the toolbar at render time; SSML payloads are
  restricted to an allow-listed subset of SSML tags/attributes before
  being forwarded to TTS providers. Do not ship tools that rely on raw
  `<script>` or event-handler attributes in their icon strings.

## Related Documentation

- **[Tool Registry Architecture](docs/TOOL_REGISTRY.md)** - ⭐ NEW - Registry-based tool management and QTI 3.0 PNP support
- **[PNP Configuration Guide](docs/PNP_CONFIGURATION.md)** - ⭐ NEW - How to configure student profiles, district policies, and governance rules
- [ToolkitCoordinator Architecture](../../docs/architecture/architecture.md#toolkitcoordinator-centralized-service-management) - Design decisions and patterns
- [Section Player README](../section-player/README.md) - Section player integration
- [Section Player Architecture](../section-player/ARCHITECTURE.md#layered-runtime-engine-post-m7) - Layered runtime engine, kernel/toolkit wiring, lifecycle emit invariant
- [Framework-Owned Error Handling](../../docs/tools-and-accomodations/framework-owned-error-handling.md) - Canonical framework error model/events and fallback behavior
- [Safe Custom Tool Configuration](../../docs/tools-and-accomodations/safe-custom-tool-config.md) - Host-side config patterns and validation guidance
- [Architecture Overview](../../docs/architecture/architecture.md) - Complete system architecture

## License

MIT
