has no native `disabled`; disabled state is conveyed via ARIA.
+ itemState.setInteractionsEnabled(false);
+ expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true');
+
+ // Activation is a no-op while disabled.
+ buttons[0]!.click();
+ expect(buttons[0]!.getAttribute('aria-pressed')).toBe('false');
+ expect(itemState.collectAll().responses).toEqual({ RESPONSE: null });
+
+ itemState.setInteractionsEnabled(true);
+ expect(buttons[0]!.hasAttribute('aria-disabled')).toBe(false);
+
+ buttons[0]!.click();
+ expect(buttons[0]!.getAttribute('aria-pressed')).toBe('true');
+ });
+ });
+});
diff --git a/packages/cutie-client/src/transformer/handlers/hottextInteraction.ts b/packages/cutie-client/src/transformer/handlers/hottextInteraction.ts
new file mode 100644
index 0000000..0449f3f
--- /dev/null
+++ b/packages/cutie-client/src/transformer/handlers/hottextInteraction.ts
@@ -0,0 +1,490 @@
+/* spell-checker: ignore hottext radiogroup deselectable */
+import {
+ createInvalidAttributeError,
+ createMissingAttributeError,
+} from '../../errors/errorDisplay';
+import {
+ type ConstraintMessage,
+ createConstraintMessage,
+} from '../../errors/validationDisplay';
+import { announce } from '../../utils/liveRegion';
+import {
+ focusNext,
+ focusPrev,
+ initializeRovingTabindex,
+ updateRovingTabindex,
+} from '../../utils/rovingTabindex';
+import { registry } from '../registry';
+import type { ElementHandler, TransformContext } from '../types';
+import { getDefaultValue } from './responseUtils';
+
+/**
+ * Build constraint text describing the selection requirements.
+ * Returns null when no instructional text is needed (e.g., single-select
+ * without a minimum).
+ */
+function buildConstraintText(minChoices: number, maxChoices: number, isSingleSelect: boolean): string | null {
+ if (isSingleSelect) {
+ return minChoices > 0 ? 'Select an answer.' : null;
+ }
+ if (minChoices > 0 && maxChoices > 0 && minChoices !== maxChoices) {
+ return `Select between ${minChoices} and ${maxChoices} items.`;
+ }
+ if (minChoices > 0) {
+ return `Select at least ${minChoices} item${minChoices === 1 ? '' : 's'}.`;
+ }
+ if (maxChoices > 0) {
+ return `Select up to ${maxChoices} item${maxChoices === 1 ? '' : 's'}.`;
+ }
+ return null;
+}
+
+/**
+ * Read the cardinality of a response declaration.
+ * Mirrors the lookup used by getDefaultValue (queries the owning document for
+ * the qti-response-declaration with the given identifier).
+ */
+function getResponseCardinality(
+ doc: Document | null,
+ responseIdentifier: string
+): string | null {
+ if (!doc) return null;
+ const responseDeclaration = doc.querySelector(
+ `qti-response-declaration[identifier="${responseIdentifier}"]`
+ );
+ return responseDeclaration?.getAttribute('cardinality') ?? null;
+}
+
+/**
+ * Handler for qti-hottext elements within a qti-hottext-interaction.
+ * Renders each selectable word as an inert inline toggle button. The parent
+ * HottextInteractionHandler wires up all behavior after transforming content.
+ */
+class HottextHandler implements ElementHandler {
+ canHandle(element: Element): boolean {
+ return element.tagName.toLowerCase() === 'qti-hottext';
+ }
+
+ transform(element: Element, context: TransformContext): DocumentFragment {
+ const fragment = document.createDocumentFragment();
+
+ if (context.styleManager && !context.styleManager.hasStyle('cutie-hottext')) {
+ context.styleManager.addStyle('cutie-hottext', HOTTEXT_STYLES);
+ }
+
+ const identifier = element.getAttribute('identifier');
+ if (!identifier) {
+ console.warn('qti-hottext missing identifier attribute');
+ const span = document.createElement('span');
+ span.className = 'cutie-hottext cutie-hottext--error';
+ span.textContent = '[missing identifier]';
+ fragment.appendChild(span);
+ return fragment;
+ }
+
+ // Render an inert inline toggle. Behavior is attached by the parent
+ // interaction handler after it finds the button via querySelectorAll.
+ const span = document.createElement('span');
+ span.className = 'cutie-hottext';
+ span.setAttribute('tabindex', '0');
+ span.setAttribute('role', 'button');
+ span.setAttribute('data-hottext-identifier', identifier);
+ span.setAttribute('aria-pressed', 'false');
+
+ if (context.transformChildren) {
+ span.appendChild(context.transformChildren(element));
+ }
+
+ fragment.appendChild(span);
+ return fragment;
+ }
+}
+
+/**
+ * Handler for qti-hottext-interaction elements.
+ * Renders the surrounding flow content (which contains inline qti-hottext
+ * toggles), then wires up selection, min/max enforcement, validation, a
+ * response accessor, and an enabled-state observer.
+ */
+class HottextInteractionHandler implements ElementHandler {
+ canHandle(element: Element): boolean {
+ return element.tagName.toLowerCase() === 'qti-hottext-interaction';
+ }
+
+ transform(element: Element, context: TransformContext): DocumentFragment {
+ const fragment = document.createDocumentFragment();
+
+ // Register styles once
+ if (context.styleManager && !context.styleManager.hasStyle('cutie-hottext-interaction')) {
+ context.styleManager.addStyle('cutie-hottext-interaction', HOTTEXT_INTERACTION_STYLES);
+ }
+
+ // Required response-identifier
+ const responseIdentifier = element.getAttribute('response-identifier');
+ if (!responseIdentifier) {
+ fragment.appendChild(
+ createMissingAttributeError('qti-hottext-interaction', 'response-identifier')
+ );
+ return fragment;
+ }
+
+ // Single-vs-multi select is determined by the response declaration's
+ // cardinality, NOT by max-choices (see class docs).
+ const cardinality = getResponseCardinality(element.ownerDocument, responseIdentifier);
+ const isSingleSelect = cardinality === 'single';
+
+ // Parse max-choices (default 1 per the QTI v3 HotTextInteractionDType XSD)
+ // as a constraint only.
+ const maxChoicesAttr = element.getAttribute('max-choices') ?? '1';
+ const maxChoices = parseInt(maxChoicesAttr, 10);
+ if (isNaN(maxChoices)) {
+ fragment.appendChild(
+ createInvalidAttributeError('qti-hottext-interaction', 'max-choices', maxChoicesAttr, 'Value must be a number')
+ );
+ return fragment;
+ }
+
+ // Parse optional min-choices attribute
+ const minChoicesAttr = element.getAttribute('min-choices');
+ const minChoices = minChoicesAttr ? parseInt(minChoicesAttr, 10) : 0;
+ if (minChoicesAttr && isNaN(minChoices)) {
+ fragment.appendChild(
+ createInvalidAttributeError('qti-hottext-interaction', 'min-choices', minChoicesAttr, 'Value must be a number')
+ );
+ return fragment;
+ }
+
+ // Create the interaction container (acts as the accessible group)
+ const container = document.createElement('div');
+ const sourceClasses = element.getAttribute('class');
+ container.className = sourceClasses
+ ? `cutie-hottext-interaction ${sourceClasses}`
+ : 'cutie-hottext-interaction';
+ container.setAttribute('data-response-identifier', responseIdentifier);
+ container.setAttribute('role', isSingleSelect ? 'radiogroup' : 'group');
+ if (isSingleSelect && minChoices >= 1) {
+ container.setAttribute('aria-required', 'true');
+ }
+
+ const children = Array.from(element.children);
+ const promptElement = children.find(
+ (child) => child.tagName.toLowerCase() === 'qti-prompt'
+ );
+
+ // Render prompt if present, and label the group with it
+ const promptId = `prompt-${responseIdentifier}`;
+ if (promptElement && context.transformChildren) {
+ const promptDiv = document.createElement('div');
+ promptDiv.className = 'cutie-prompt';
+ promptDiv.id = promptId;
+ promptDiv.appendChild(context.transformChildren(promptElement));
+ container.appendChild(promptDiv);
+ container.setAttribute('aria-labelledby', promptId);
+ } else {
+ // No prompt: give the group a fallback accessible name so screen
+ // readers announce more than a bare "group" (mirrors gap-match).
+ container.setAttribute('aria-label', 'Hottext interaction');
+ }
+
+ // Transform the interaction's flow content (everything except the prompt)
+ // into a content container. Transforming a clone of the interaction — rather
+ // than each child element in isolation — preserves block wrappers like
+ // and the whitespace text nodes between them, so multi-paragraph passages
+ // render faithfully instead of collapsing into run-together text. This also
+ // invokes HottextHandler for each inline qti-hottext element.
+ const contentContainer = document.createElement('div');
+ contentContainer.className = 'cutie-hottext-content';
+ if (context.transformChildren) {
+ const contentSource = element.cloneNode(true) as Element;
+ for (const child of Array.from(contentSource.children)) {
+ if (child.tagName.toLowerCase() === 'qti-prompt') child.remove();
+ }
+ contentContainer.appendChild(context.transformChildren(contentSource));
+ }
+ container.appendChild(contentContainer);
+
+ const buttons = Array.from(
+ contentContainer.querySelectorAll('[data-hottext-identifier]')
+ );
+
+ const getIdentifier = (button: HTMLElement): string =>
+ button.getAttribute('data-hottext-identifier') ?? '';
+
+ const selAttr = isSingleSelect ? 'aria-checked' : 'aria-pressed';
+ const isSelected = (button: HTMLElement): boolean =>
+ button.getAttribute(selAttr) === 'true';
+ const setSelected = (button: HTMLElement, on: boolean): void => {
+ button.setAttribute(selAttr, String(on));
+ };
+ const selectedCount = (): number => buttons.filter(isSelected).length;
+
+ const isDisabled = (button: HTMLElement): boolean =>
+ button.getAttribute('aria-disabled') === 'true';
+
+ // Convert the toggle buttons into radios for single-select
+ const radioMap = new Map();
+ if (isSingleSelect) {
+ for (const button of buttons) {
+ button.setAttribute('role', 'radio');
+ button.setAttribute('aria-checked', 'false');
+ button.removeAttribute('aria-pressed');
+ radioMap.set(getIdentifier(button), button);
+ }
+ }
+
+ const selectExclusive = (button: HTMLElement): void => {
+ for (const other of buttons) setSelected(other, false);
+ setSelected(button, true);
+ };
+
+ // Constraint message
+ const minSelectionsMessage = element.getAttribute('data-min-selections-message');
+ const maxSelectionsMessage = element.getAttribute('data-max-selections-message');
+ let constraint: ConstraintMessage | undefined;
+ const hintText = buildConstraintText(minChoices, maxChoices, isSingleSelect);
+ if (hintText) {
+ constraint = createConstraintMessage(
+ `constraint-${responseIdentifier}`,
+ hintText,
+ context.styleManager,
+ );
+ container.appendChild(constraint.element);
+
+ const existingDescribedBy = container.getAttribute('aria-describedby');
+ container.setAttribute(
+ 'aria-describedby',
+ existingDescribedBy
+ ? `${existingDescribedBy} ${constraint.element.id}`
+ : constraint.element.id
+ );
+ }
+
+ // Apply default value(s) from the response declaration if present
+ const defaultValue = getDefaultValue(element.ownerDocument, responseIdentifier);
+ if (defaultValue !== null) {
+ const defaults = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
+ for (const button of buttons) {
+ if (defaults.includes(getIdentifier(button))) {
+ setSelected(button, true);
+ }
+ }
+ }
+
+ if (isSingleSelect) {
+ const checked = buttons.find(isSelected);
+ if (checked) {
+ updateRovingTabindex(radioMap, checked);
+ } else {
+ initializeRovingTabindex(radioMap);
+ }
+ }
+
+ const checkValidity = (): boolean => {
+ const count = selectedCount();
+ return (minChoices <= 0 || count >= minChoices) &&
+ (maxChoices <= 0 || isSingleSelect || count <= maxChoices);
+ };
+
+ const clearErrors = () => {
+ container.removeAttribute('aria-invalid');
+ constraint?.setError(false);
+ if (hintText) constraint?.setText(hintText);
+ };
+
+ const showErrors = () => {
+ container.setAttribute('aria-invalid', 'true');
+ constraint?.setError(true);
+
+ const count = selectedCount();
+ if (!isSingleSelect && maxChoices > 0 && count > maxChoices) {
+ const msg = maxSelectionsMessage ?? hintText;
+ if (msg) constraint?.setText(msg);
+ } else if (minChoices > 0 && count < minChoices) {
+ const msg = minSelectionsMessage ?? hintText;
+ if (msg) constraint?.setText(msg);
+ }
+ };
+
+ const activate = (button: HTMLElement): void => {
+ if (isDisabled(button)) return;
+
+ if (isSingleSelect) {
+ // Radio semantics: activating selects and clears siblings.
+ selectExclusive(button);
+ updateRovingTabindex(radioMap, button);
+ } else if (isSelected(button)) {
+ setSelected(button, false);
+ } else {
+ if (maxChoices > 0 && selectedCount() >= maxChoices) {
+ const msg = maxSelectionsMessage ?? hintText;
+ if (msg) announce(context, msg, 'assertive');
+ return;
+ }
+ setSelected(button, true);
+ }
+
+ if (checkValidity()) clearErrors();
+ };
+
+ for (const button of buttons) {
+ button.addEventListener('click', () => activate(button));
+
+ button.addEventListener('keydown', (event) => {
+ if (isDisabled(button)) return;
+
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ activate(button);
+ return;
+ }
+
+ if (!isSingleSelect) return;
+
+ const isNext = event.key === 'ArrowDown' || event.key === 'ArrowRight';
+ const isPrev = event.key === 'ArrowUp' || event.key === 'ArrowLeft';
+ if (!isNext && !isPrev) return;
+
+ event.preventDefault();
+ const id = getIdentifier(button);
+ const moved = isNext
+ ? focusNext(radioMap, id)
+ : focusPrev(radioMap, id);
+ if (moved) {
+ selectExclusive(moved);
+ if (checkValidity()) clearErrors();
+ }
+ });
+ }
+
+ // Register response accessor with itemState
+ if (context.itemState) {
+ const getResponse = (): string | string[] | null => {
+ const selected = buttons.filter(isSelected).map(getIdentifier);
+ if (selected.length === 0) return null;
+ return isSingleSelect ? (selected[0] ?? null) : selected;
+ };
+
+ const accessor = () => {
+ const value = getResponse();
+ const valid = checkValidity();
+ if (valid) { clearErrors(); } else { showErrors(); }
+ return { value, valid };
+ };
+
+ context.itemState.registerResponse(responseIdentifier, accessor);
+
+ // Observe interaction enabled state changes
+ const updateInteractionState = (state: { interactionsEnabled: boolean }) => {
+ const isEnabled = state.interactionsEnabled;
+ for (const button of buttons) {
+ if (isEnabled) {
+ button.removeAttribute('aria-disabled');
+ } else {
+ button.setAttribute('aria-disabled', 'true');
+ }
+ }
+ };
+
+ context.itemState.addObserver(updateInteractionState);
+
+ // Set initial state
+ updateInteractionState({ interactionsEnabled: context.itemState.interactionsEnabled });
+ }
+
+ fragment.appendChild(container);
+ return fragment;
+ }
+}
+
+// Register HottextHandler (priority 45) so it is checked before the interaction
+// handler; HottextInteractionHandler (priority 50) matches the wrapper element.
+registry.register('hottext', new HottextHandler(), 45);
+registry.register('hottext-interaction', new HottextInteractionHandler(), 50);
+
+/**
+ * CSS styles for inline hottext toggle words.
+ */
+const HOTTEXT_STYLES = `
+ .cutie-hottext {
+ display: inline;
+ padding: 0.05em 0.2em;
+ margin: 0;
+ border: none;
+ border-radius: 3px;
+ background-color: transparent;
+ color: inherit;
+ font: inherit;
+ line-height: inherit;
+ text-align: inherit;
+ cursor: pointer;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ text-underline-offset: 0.2em;
+ transition: background-color 0.2s, color 0.2s, text-decoration-color 0.2s;
+ }
+
+ .cutie-hottext:hover {
+ text-decoration-style: solid;
+ }
+
+ .cutie-hottext:focus-visible {
+ outline: 2px solid var(--cutie-primary);
+ outline-offset: 2px;
+ }
+
+ .cutie-hottext[aria-pressed="true"],
+ .cutie-hottext[aria-checked="true"] {
+ background-color: var(--cutie-primary);
+ color: var(--cutie-bg);
+ text-decoration: none;
+ }
+
+ .cutie-hottext[aria-pressed="true"]:hover,
+ .cutie-hottext[aria-checked="true"]:hover {
+ background-color: var(--cutie-primary);
+ }
+
+ .cutie-hottext[aria-disabled="true"] {
+ cursor: default;
+ opacity: 0.7;
+ }
+
+ .cutie-hottext[aria-disabled="true"]:hover {
+ background-color: transparent;
+ }
+
+ .cutie-hottext[aria-pressed="true"][aria-disabled="true"],
+ .cutie-hottext[aria-checked="true"][aria-disabled="true"] {
+ background-color: var(--cutie-primary);
+ opacity: 0.7;
+ }
+
+ .cutie-hottext--error {
+ color: var(--cutie-feedback-incorrect);
+ text-decoration: underline;
+ text-decoration-style: wavy;
+ }
+`;
+
+/**
+ * CSS styles for the hottext interaction container.
+ */
+const HOTTEXT_INTERACTION_STYLES = `
+ .cutie-hottext-interaction {
+ margin: 1em 0;
+ }
+
+ .cutie-hottext-interaction .cutie-prompt {
+ font-weight: 600;
+ margin-bottom: 0.75em;
+ color: var(--cutie-text);
+ }
+
+ .cutie-hottext-interaction .cutie-hottext-content {
+ line-height: 1.8;
+ }
+
+ .cutie-hottext-interaction .cutie-hottext-content .cutie-hottext {
+ overflow-wrap: break-word;
+ }
+`;
diff --git a/packages/cutie-client/src/transformer/handlers/index.ts b/packages/cutie-client/src/transformer/handlers/index.ts
index a045e16..9d60f1c 100644
--- a/packages/cutie-client/src/transformer/handlers/index.ts
+++ b/packages/cutie-client/src/transformer/handlers/index.ts
@@ -1,3 +1,4 @@
+/* spell-checker: ignore hottext */
/**
* Handler registration module
* Import all handlers to trigger side-effect registration with the registry
@@ -7,6 +8,7 @@
import './choiceInteraction'; // priority 50
import './extendedText'; // priorities 40-50
import './gapMatchInteraction'; // priority 45 (gap), 50 (gap-match-interaction)
+import './hottextInteraction'; // priority 45 (hottext), 50 (hottext-interaction)
import './inlineChoiceInteraction'; // priority 50
import './matchInteraction'; // priority 50
import './textEntryInteraction'; // priority 50
diff --git a/packages/cutie-core/src/lib/validateResponses.spec.ts b/packages/cutie-core/src/lib/validateResponses.spec.ts
index 408614b..04b9e6f 100644
--- a/packages/cutie-core/src/lib/validateResponses.spec.ts
+++ b/packages/cutie-core/src/lib/validateResponses.spec.ts
@@ -20,6 +20,21 @@ const makeItem = (interactionAttrs: string) => `
`;
+// spell-checker: ignore hottext
+const makeHottextItem = (attrs: string) => `
+
+
+
+
+ Alpha Item A
+ beta Item B
+ gamma Item C.
+
+
+
+
+`;
+
const makeTextEntryItem = (attrs: string) => `
@@ -158,6 +173,55 @@ describe('validateSubmission', () => {
});
});
+describe('validateHottextInteractions', () => {
+ it('passes when min-choices and max-choices are satisfied', () => {
+ const doc = parseItem(makeHottextItem('min-choices="1" max-choices="2"'));
+ expect(() => validateSubmission({ RESPONSE: ['B', 'C'] }, doc)).not.toThrow();
+ });
+
+ it('fails when too few selected', () => {
+ const doc = parseItem(makeHottextItem('min-choices="2" max-choices="3"'));
+ expect(() => validateSubmission({ RESPONSE: ['B'] }, doc)).toThrow(ResponseValidationError);
+
+ try {
+ validateSubmission({ RESPONSE: ['B'] }, doc);
+ } catch (e) {
+ const err = e as ResponseValidationError;
+ expect(err.errors).toHaveLength(1);
+ expect(err.errors[0]!.constraint).toBe('min-choices');
+ }
+ });
+
+ it('fails when too many selected (server guard)', () => {
+ const doc = parseItem(makeHottextItem('max-choices="2"'));
+ expect(() => validateSubmission({ RESPONSE: ['A', 'B', 'C'] }, doc)).toThrow(
+ ResponseValidationError
+ );
+
+ try {
+ validateSubmission({ RESPONSE: ['A', 'B', 'C'] }, doc);
+ } catch (e) {
+ const err = e as ResponseValidationError;
+ expect(err.errors[0]!.constraint).toBe('max-choices');
+ }
+ });
+
+ it('fails when null response violates min-choices', () => {
+ const doc = parseItem(makeHottextItem('min-choices="1"'));
+ expect(() => validateSubmission({ RESPONSE: null }, doc)).toThrow(ResponseValidationError);
+ });
+
+ it('passes for single-select scalar response within max-choices="1"', () => {
+ const doc = parseItem(makeHottextItem('max-choices="1"'));
+ expect(() => validateSubmission({ RESPONSE: 'B' }, doc)).not.toThrow();
+ });
+
+ it('passes with no constraints', () => {
+ const doc = parseItem(makeHottextItem('max-choices="0"'));
+ expect(() => validateSubmission({ RESPONSE: [] }, doc)).not.toThrow();
+ });
+});
+
describe('validateTextEntryInteractions', () => {
it('passes when value matches pattern-mask', () => {
const doc = parseItem(makeTextEntryItem('pattern-mask="^\\d{3}$"'));
diff --git a/packages/cutie-core/src/lib/validateResponses.ts b/packages/cutie-core/src/lib/validateResponses.ts
index a80422b..3bd5dd2 100644
--- a/packages/cutie-core/src/lib/validateResponses.ts
+++ b/packages/cutie-core/src/lib/validateResponses.ts
@@ -29,6 +29,7 @@ export function validateSubmission(submission: ResponseData, itemDoc: Document):
const errors: ValidationError[] = [];
validateChoiceInteractions(submission, itemDoc, errors);
+ validateHottextInteractions(submission, itemDoc, errors);
validateTextEntryInteractions(submission, itemDoc, errors);
validateExtendedTextInteractions(submission, itemDoc, errors);
validateInlineChoiceInteractions(submission, itemDoc, errors);
@@ -83,6 +84,48 @@ function validateChoiceInteractions(
}
}
+/* spell-checker: ignore hottext */
+function validateHottextInteractions(
+ submission: ResponseData,
+ itemDoc: Document,
+ errors: ValidationError[]
+): void {
+ const interactions = itemDoc.getElementsByTagName('qti-hottext-interaction');
+
+ for (let i = 0; i < interactions.length; i++) {
+ const interaction = interactions[i]!;
+ const responseIdentifier = interaction.getAttribute('response-identifier');
+ if (!responseIdentifier) continue;
+
+ const response = submission[responseIdentifier];
+ const selectedCount = getSelectedCount(response);
+
+ const minChoicesAttr = interaction.getAttribute('min-choices');
+ if (minChoicesAttr) {
+ const minChoices = parseInt(minChoicesAttr, 10);
+ if (!isNaN(minChoices) && minChoices > 0 && selectedCount < minChoices) {
+ errors.push({
+ responseIdentifier,
+ constraint: 'min-choices',
+ message: `Expected at least ${minChoices} choice(s), got ${selectedCount}`,
+ });
+ }
+ }
+
+ const maxChoicesAttr = interaction.getAttribute('max-choices');
+ if (maxChoicesAttr) {
+ const maxChoices = parseInt(maxChoicesAttr, 10);
+ if (!isNaN(maxChoices) && maxChoices > 0 && selectedCount > maxChoices) {
+ errors.push({
+ responseIdentifier,
+ constraint: 'max-choices',
+ message: `Expected at most ${maxChoices} choice(s), got ${selectedCount}`,
+ });
+ }
+ }
+ }
+}
+
function getSelectedCount(response: unknown): number {
if (response == null) return 0;
if (Array.isArray(response)) return response.length;
diff --git a/packages/cutie-example/src/example-items/hottext-highlight-findings.ts b/packages/cutie-example/src/example-items/hottext-highlight-findings.ts
new file mode 100644
index 0000000..d6e185a
--- /dev/null
+++ b/packages/cutie-example/src/example-items/hottext-highlight-findings.ts
@@ -0,0 +1,85 @@
+// Source: Project Cutie
+/* spell-checker: ignore Hottext hottext */
+
+export const name = "Hottext Interaction - Highlight Critical Findings";
+
+export const item = `
+
+
+
+ B
+ C
+ D
+ E
+ F
+ G
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A nurse is reviewing a client's wellness-visit notes. Highlight the statements
+ that indicate the client needs follow-up teaching about healthy sleep habits.
+
+
+
+
+ The client keeps a consistent bedtime on weeknights.
+ The client drinks coffee in the late afternoon.
+ The client scrolls on their phone in bed until falling asleep.
+ The client keeps the bedroom television on while falling asleep.
+ The client keeps the bedroom warm and brightly lit at night.
+ The client checks work email in bed each night.
+ The client takes long naps in the early evening.
+ The client goes for a short walk after dinner.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+
+
+`;
+
+export const interactionTypes: string[] = ['hottext'];
diff --git a/packages/cutie-example/src/example-items/hottext-highlight-table.ts b/packages/cutie-example/src/example-items/hottext-highlight-table.ts
new file mode 100644
index 0000000..8b883e0
--- /dev/null
+++ b/packages/cutie-example/src/example-items/hottext-highlight-table.ts
@@ -0,0 +1,104 @@
+// Source: Project Cutie
+/* spell-checker: ignore Hottext hottext */
+
+export const name = "Hottext Interaction - Highlighting Table";
+
+export const item = `
+
+
+
+ A
+ B
+ C
+ E
+
+
+
+
+
+
+
+
+
+
+
+
+ Before a long drive, you check the dashboard.
+ Click to indicate the readings that need attention before you go.
+
+
+
+
+ Dashboard Readings
+
+
+ | Gauge |
+ Reading |
+
+
+
+
+ | Fuel |
+ Nearly empty |
+
+
+ | Tire pressure |
+ 18 PSI |
+
+
+ | Engine temperature |
+ Overheating |
+
+
+ | Oil level |
+ Full |
+
+
+ | Brake warning light |
+ Illuminated |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+
+
+`;
+
+export const interactionTypes: string[] = ['hottext'];
diff --git a/packages/cutie-example/src/example-items/hottext-multiple.ts b/packages/cutie-example/src/example-items/hottext-multiple.ts
new file mode 100644
index 0000000..b9cdc9f
--- /dev/null
+++ b/packages/cutie-example/src/example-items/hottext-multiple.ts
@@ -0,0 +1,127 @@
+// Source: Project Cutie
+/* spell-checker: ignore Hottext hottext */
+
+export const name = "Hottext Interaction - Selecting Multiple";
+
+export const item = `
+
+
+
+ B
+ C
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+
+
+
+
+ Two of the four marked items below belong to the target set.
+ Select Item B and Item C.
+
+
+
+ The passage opens with background context, introduced through
+ Item AItem A is background context, not a member of the target set..
+ The target set's first member is
+ Item BItem B belongs to the target set — correct..
+ The target set continues with
+ Item CItem C also belongs to the target set — correct.,
+ and the passage closes with a concluding remark,
+ Item DItem D is a concluding remark outside the target set..
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+ A
+
+
+
+
+
+ RESPONSE_choice_A
+
+
+
+
+
+
+
+
+ B
+
+
+
+
+
+ RESPONSE_choice_B
+
+
+
+
+
+
+
+
+ C
+
+
+
+
+
+ RESPONSE_choice_C
+
+
+
+
+
+
+
+
+ D
+
+
+
+
+
+ RESPONSE_choice_D
+
+
+
+
+
+`;
+
+export const interactionTypes: string[] = ['hottext'];
diff --git a/packages/cutie-example/src/example-items/index.ts b/packages/cutie-example/src/example-items/index.ts
index 87546ab..0b1b21f 100644
--- a/packages/cutie-example/src/example-items/index.ts
+++ b/packages/cutie-example/src/example-items/index.ts
@@ -24,6 +24,9 @@ import * as hotspot from './hotspot';
import * as extendedText from './extended-text';
import * as associate from './associate';
import * as hottext from './hottext';
+import * as hottextMultiple from './hottext-multiple';
+import * as hottextHighlightFindings from './hottext-highlight-findings';
+import * as hottextHighlightTable from './hottext-highlight-table';
import * as math from './math';
import * as selectPoint from './select-point';
import * as multiInput from './multi-input';
@@ -70,6 +73,9 @@ export const specExamples = [
extendedText,
associate,
hottext,
+ hottextMultiple,
+ hottextHighlightFindings,
+ hottextHighlightTable,
math,
selectPoint,
multiInput,